diff --git a/app/controllers/components/course/scholaistic_component.rb b/app/controllers/components/course/scholaistic_component.rb new file mode 100644 index 00000000000..e1fec99e0b1 --- /dev/null +++ b/app/controllers/components/course/scholaistic_component.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true +class Course::ScholaisticComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + include Course::Scholaistic::Concern + + def self.display_name + I18n.t('components.scholaistic.name') + end + + def self.enabled_by_default? + false + end + + def sidebar_items + main_sidebar_items + settings_sidebar_items + end + + private + + def main_sidebar_items + return [] unless scholaistic_course_linked? + + student_sidebar_items + admin_sidebar_items + end + + def student_sidebar_items + [ + { + key: :scholaistic_assessments, + icon: :chatbot, + title: settings.assessments_title || I18n.t('course.scholaistic.assessments'), + weight: 4, + path: course_scholaistic_assessments_path(current_course) + } + ] + assistant_sidebar_items + end + + def assistant_sidebar_items + ScholaisticApiService.assistants!(current_course).map do |assistant| + { + key: "scholaistic_assistant_#{assistant[:id]}", + icon: :chatbot, + title: assistant[:sidebar_title] || assistant[:title], + weight: 4.5, + path: course_scholaistic_assistant_path(current_course, assistant[:id]) + } + end + rescue StandardError => e + Rails.logger.error("Failed to load Scholaistic assistants: #{e.message}") + raise e unless Rails.env.production? + + [] + end + + def admin_sidebar_items + return [] unless can?(:manage_scholaistic_assistants, current_course) + + [ + { + key: :scholaistic_assistants, + type: :admin, + icon: :chatbot, + title: I18n.t('components.scholaistic.manage_assistants'), + weight: 9, + path: course_scholaistic_assistants_path(current_course), + exact: true + } + ] + end + + def settings_sidebar_items + [ + { + type: :settings, + title: I18n.t('components.scholaistic.name'), + weight: 5, + path: course_admin_scholaistic_path(current_course) + } + ] + end +end diff --git a/app/controllers/concerns/course/scholaistic/concern.rb b/app/controllers/concerns/course/scholaistic/concern.rb new file mode 100644 index 00000000000..a29d8ec9caf --- /dev/null +++ b/app/controllers/concerns/course/scholaistic/concern.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +module Course::Scholaistic::Concern + extend ActiveSupport::Concern + + private + + def scholaistic_course_linked? + current_course.component_enabled?(Course::ScholaisticComponent) && + current_course.settings(:course_scholaistic_component)&.integration_key.present? + end +end diff --git a/app/controllers/course/achievement/condition/scholaistic_assessments_controller.rb b/app/controllers/course/achievement/condition/scholaistic_assessments_controller.rb new file mode 100644 index 00000000000..3445ad0dd4d --- /dev/null +++ b/app/controllers/course/achievement/condition/scholaistic_assessments_controller.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true +class Course::Achievement::Condition::ScholaisticAssessmentsController < + Course::Condition::ScholaisticAssessmentsController + include Course::AchievementConditionalConcern +end diff --git a/app/controllers/course/admin/controller.rb b/app/controllers/course/admin/controller.rb index 2fdea0000b4..ad335e386bd 100644 --- a/app/controllers/course/admin/controller.rb +++ b/app/controllers/course/admin/controller.rb @@ -5,7 +5,7 @@ class Course::Admin::Controller < Course::ComponentController private def authorize_admin - authorize!(:manage, current_course) + authorize!(:manage, current_course) unless publicly_accessible? end # @return [Course::SettingsComponent] diff --git a/app/controllers/course/admin/scholaistic_settings_controller.rb b/app/controllers/course/admin/scholaistic_settings_controller.rb new file mode 100644 index 00000000000..650c098adfe --- /dev/null +++ b/app/controllers/course/admin/scholaistic_settings_controller.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +class Course::Admin::ScholaisticSettingsController < Course::Admin::Controller + skip_forgery_protection only: :confirm_link_course + skip_authorize_resource :course, only: :confirm_link_course + + def edit + render_settings + end + + def update + if @settings.update(params) && current_course.save + render_settings + else + render json: { errors: @settings.errors }, status: :bad_request + end + end + + def confirm_link_course + key = ScholaisticApiService.parse_link_course_callback_request(request, params) + head :bad_request and return if key.blank? + + @settings.update(integration_key: key, last_synced_at: nil) && current_course.save + end + + def link_course + head :bad_request and return if @settings.integration_key.present? + + render json: { + redirectUrl: ScholaisticApiService.link_course_url!( + course_title: current_course.title, + course_url: course_url(current_course), + callback_url: course_admin_scholaistic_confirm_link_course_url(current_course) + ) + } + end + + def unlink_course + head :ok and return if @settings.integration_key.blank? + + ActiveRecord::Base.transaction do + ScholaisticApiService.unlink_course!(@settings.integration_key) + + raise ActiveRecord::Rollback unless current_course.scholaistic_assessments.destroy_all + + @settings.update(integration_key: nil, last_synced_at: nil) + current_course.save! + end + + render_settings + rescue ActiveRecord::Rollback + render json: { errors: @settings.errors }, status: :bad_request + end + + protected + + def publicly_accessible? + action_name.to_sym == :confirm_link_course + end + + private + + def scholaistic_settings_params + params.require(:settings_scholaistic_component).permit(:assessments_title) + end + + def component + current_component_host[:course_scholaistic_component] + end + + def render_settings + @ping_result = ScholaisticApiService.ping_course(@settings.integration_key) if @settings.integration_key.present? + render 'edit' + end +end diff --git a/app/controllers/course/assessment/condition/scholaistic_assessments_controller.rb b/app/controllers/course/assessment/condition/scholaistic_assessments_controller.rb new file mode 100644 index 00000000000..aaa85cbbf7b --- /dev/null +++ b/app/controllers/course/assessment/condition/scholaistic_assessments_controller.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true +class Course::Assessment::Condition::ScholaisticAssessmentsController < + Course::Condition::ScholaisticAssessmentsController + include Course::AssessmentConditionalConcern +end diff --git a/app/controllers/course/condition/scholaistic_assessments_controller.rb b/app/controllers/course/condition/scholaistic_assessments_controller.rb new file mode 100644 index 00000000000..f2e67670f8f --- /dev/null +++ b/app/controllers/course/condition/scholaistic_assessments_controller.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +class Course::Condition::ScholaisticAssessmentsController < Course::ConditionsController + load_resource :scholaistic_assessment_condition, class: Course::Condition::ScholaisticAssessment.name, parent: false + before_action :set_course_and_conditional, only: [:create] + authorize_resource :scholaistic_assessment_condition, class: Course::Condition::ScholaisticAssessment.name + + def index + render_available_scholaistic_assessments + end + + def show + render_available_scholaistic_assessments + end + + def create + try_to_perform @scholaistic_assessment_condition.save + end + + def update + try_to_perform @scholaistic_assessment_condition.update(scholaistic_assessment_condition_params) + end + + def destroy + try_to_perform @scholaistic_assessment_condition.destroy + end + + private + + def render_available_scholaistic_assessments + scholaistic_assessments = current_course.scholaistic_assessments + existing_conditions = @conditional.specific_conditions - [@scholaistic_assessment_condition] + @available_assessments = (scholaistic_assessments - existing_conditions.map(&:dependent_object)).sort_by(&:title) + render 'available_scholaistic_assessments' + end + + def try_to_perform(operation_succeeded) + if operation_succeeded + success_action + else + render json: { errors: @scholaistic_assessment_condition.errors }, status: :bad_request + end + end + + def scholaistic_assessment_condition_params + params.require(:condition_scholaistic_assessment).permit(:scholaistic_assessment_id) + end + + def set_course_and_conditional + @scholaistic_assessment_condition.course = current_course + @scholaistic_assessment_condition.conditional = @conditional + end + + def component + current_component_host[:course_scholaistic_component] + end +end diff --git a/app/controllers/course/scholaistic/assistants_controller.rb b/app/controllers/course/scholaistic/assistants_controller.rb new file mode 100644 index 00000000000..7a4f2fb1a01 --- /dev/null +++ b/app/controllers/course/scholaistic/assistants_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +class Course::Scholaistic::AssistantsController < Course::Scholaistic::Controller + def index + authorize! :manage_scholaistic_assistants, current_course + + @embed_src = ScholaisticApiService.embed!( + current_course_user, + ScholaisticApiService.assistants_path, + request.origin + ) + end + + def show + authorize! :read_scholaistic_assistants, current_course + + @assistant_title = ScholaisticApiService.assistant!(current_course, params[:id])[:title] + + @embed_src = ScholaisticApiService.embed!( + current_course_user, + ScholaisticApiService.assistant_path(params[:id]), + request.origin + ) + end +end diff --git a/app/controllers/course/scholaistic/controller.rb b/app/controllers/course/scholaistic/controller.rb new file mode 100644 index 00000000000..f943d413cf2 --- /dev/null +++ b/app/controllers/course/scholaistic/controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +class Course::Scholaistic::Controller < Course::ComponentController + include Course::Scholaistic::Concern + + before_action :not_found_if_scholaistic_course_not_linked + + private + + def component + current_component_host[:course_scholaistic_component] + end + + def not_found_if_scholaistic_course_not_linked + head :not_found unless scholaistic_course_linked? + end +end diff --git a/app/controllers/course/scholaistic/scholaistic_assessments_controller.rb b/app/controllers/course/scholaistic/scholaistic_assessments_controller.rb new file mode 100644 index 00000000000..94400104df4 --- /dev/null +++ b/app/controllers/course/scholaistic/scholaistic_assessments_controller.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true +class Course::Scholaistic::ScholaisticAssessmentsController < Course::Scholaistic::Controller + load_and_authorize_resource :scholaistic_assessment, through: :course, class: Course::ScholaisticAssessment.name + + before_action :sync_scholaistic_assessments!, only: [:index, :show, :edit] + + def index + submissions_status_hash = ScholaisticApiService.submissions!( + @scholaistic_assessments.map(&:upstream_id), + current_course_user + ) + + @assessments_status = @scholaistic_assessments.to_h do |assessment| + submission_status = submissions_status_hash[assessment.upstream_id]&.[](:status) + + [assessment.id, + if submission_status == :graded + :submitted + elsif submission_status.present? + submission_status + else + (can?(:attempt, assessment) && (assessment.start_at <= Time.zone.now)) ? :open : :unavailable + end] + end + end + + def new + @embed_src = ScholaisticApiService.embed!( + current_course_user, + ScholaisticApiService.new_assessment_path, + request.origin + ) + end + + def show + upstream_id = @scholaistic_assessment.upstream_id + + @embed_src = + ScholaisticApiService.embed!( + current_course_user, + if can?(:update, @scholaistic_assessment) + ScholaisticApiService.edit_assessment_path(upstream_id) + else + ScholaisticApiService.assessment_path(upstream_id) + end, + request.origin + ) + end + + def edit + @embed_src = ScholaisticApiService.embed!( + current_course_user, + ScholaisticApiService.edit_assessment_details_path(@scholaistic_assessment.upstream_id), + request.origin + ) + end + + def update + if @scholaistic_assessment.update(update_params) + head :ok + else + render json: { errors: @scholaistic_assessment.errors.full_messages.to_sentence }, status: :bad_request + end + end + + private + + def update_params + params.require(:scholaistic_assessment).permit(:base_exp) + end + + def sync_scholaistic_assessments! + response = ScholaisticApiService.assessments!(current_course) + + # TODO: The SQL queries will scale proportionally with `response[:assessments].size`, + # but we won't always have to sync all assessments since there's `last_synced_at`. + # In the future, we can optimise this, but it's not easy because there are multiple + # relations to `Course::ScholaisticAssessment` that need to be updated. + ActiveRecord::Base.transaction do + response[:assessments].map do |assessment| + current_course.scholaistic_assessments.find_or_initialize_by( + upstream_id: assessment[:upstream_id] + ).tap do |scholaistic_assessment| + scholaistic_assessment.start_at = assessment[:start_at] + scholaistic_assessment.end_at = assessment[:end_at] + scholaistic_assessment.title = assessment[:title] + scholaistic_assessment.description = assessment[:description] + scholaistic_assessment.published = assessment[:published] + end.save! + end + + if response[:deleted].present? && !current_course.scholaistic_assessments. + where(upstream_id: response[:deleted]).destroy_all + raise ActiveRecord::Rollback + end + + current_course.settings(:course_scholaistic_component).public_send('last_synced_at=', response[:last_synced_at]) + + current_course.save! + end + end + + def submission_status(assessment) + end +end diff --git a/app/controllers/course/scholaistic/submissions_controller.rb b/app/controllers/course/scholaistic/submissions_controller.rb new file mode 100644 index 00000000000..37cf98c2af9 --- /dev/null +++ b/app/controllers/course/scholaistic/submissions_controller.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true +class Course::Scholaistic::SubmissionsController < Course::Scholaistic::Controller + before_action :load_and_authorize_scholaistic_assessment + + before_action :sync_scholaistic_submission!, only: [:show] + + def index + @embed_src = ScholaisticApiService.embed!( + current_course_user, + ScholaisticApiService.submissions_path(@scholaistic_assessment.upstream_id), + request.origin + ) + end + + def show + result = ScholaisticApiService.submission!(current_course, submission_id) + head :not_found and return if result[:status] == :not_found + + @creator_name = result[:creator_name] + + @embed_src = + ScholaisticApiService.embed!( + current_course_user, + if can?(:manage_scholaistic_submissions, current_course) + ScholaisticApiService.manage_submission_path(@scholaistic_assessment.upstream_id, submission_id) + else + ScholaisticApiService.submission_path(@scholaistic_assessment.upstream_id, submission_id) + end, + request.origin + ) + end + + def submission + head :not_found and return unless + can?(:attempt, @scholaistic_assessment) && @scholaistic_assessment.start_at <= Time.zone.now + + submission_id = ScholaisticApiService.find_or_create_submission!( + current_course_user, + @scholaistic_assessment.upstream_id + ) + + render json: { id: submission_id } + end + + private + + def load_and_authorize_scholaistic_assessment + @scholaistic_assessment = current_course.scholaistic_assessments.find(params[:assessment_id] || params[:id]) + authorize! :read, @scholaistic_assessment + end + + def sync_scholaistic_submission! + result = ScholaisticApiService.submission!(current_course, submission_id) + + if result[:status] != :graded + @scholaistic_assessment.submissions.where(upstream_id: submission_id).destroy_all + + return + end + + email = User::Email.find_by(email: result[:creator_email], primary: true) + creator = email && current_course.users.find(email.user_id) + submission = creator && @scholaistic_assessment.submissions.find_or_initialize_by(creator_id: creator.id) + return unless submission + + submission.upstream_id = submission_id + submission.reason = @scholaistic_assessment.title + submission.points_awarded = @scholaistic_assessment.base_exp + submission.course_user = current_course.course_users.find_by(user_id: creator.id) + submission.awarded_at = Time.zone.now + submission.awarder = User.system + + submission.save! + end + + def submission_id + params[:id] + end +end diff --git a/app/helpers/course/condition/conditions_helper.rb b/app/helpers/course/condition/conditions_helper.rb index 2c74f290fb2..d6e3866b2cf 100644 --- a/app/helpers/course/condition/conditions_helper.rb +++ b/app/helpers/course/condition/conditions_helper.rb @@ -21,6 +21,7 @@ def conditions_component_hash hash[Course::Condition::Level.name] = :course_levels_component hash[Course::Condition::Survey.name] = :course_survey_component hash[Course::Condition::Video.name] = :course_videos_component + hash[Course::Condition::ScholaisticAssessment.name] = :course_scholaistic_component end end end diff --git a/app/models/components/course/conditions_ability_component.rb b/app/models/components/course/conditions_ability_component.rb index 228cb9ded05..c5962e121ef 100644 --- a/app/models/components/course/conditions_ability_component.rb +++ b/app/models/components/course/conditions_ability_component.rb @@ -17,5 +17,6 @@ def allow_teaching_staff_manage_conditions can :manage, Course::Condition::Level, condition: { course_id: course.id } can :manage, Course::Condition::Survey, condition: { course_id: course.id } can :manage, Course::Condition::Video, condition: { course_id: course.id } + can :manage, Course::Condition::ScholaisticAssessment, condition: { course_id: course.id } end end diff --git a/app/models/components/course/scholaistic_ability_component.rb b/app/models/components/course/scholaistic_ability_component.rb new file mode 100644 index 00000000000..2f4af85bdb3 --- /dev/null +++ b/app/models/components/course/scholaistic_ability_component.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +module Course::ScholaisticAbilityComponent + include AbilityHost::Component + + def define_permissions + if course_user + can :read, Course::ScholaisticAssessment, { lesson_plan_item: { course_id: course.id, published: true } } + can :attempt, Course::ScholaisticAssessment, { lesson_plan_item: { course_id: course.id } } + can :read_scholaistic_assistants, Course, { id: course.id } + + if course_user.staff? + can :manage, Course::ScholaisticAssessment, { lesson_plan_item: { course_id: course.id } } + can :manage_scholaistic_submissions, Course, { id: course.id } + can :manage_scholaistic_assistants, Course, { id: course.id } + end + end + + super + end +end diff --git a/app/models/course.rb b/app/models/course.rb index 445cc9754e1..268f22a268d 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -88,6 +88,9 @@ class Course < ApplicationRecord has_one :duplication_traceable, class_name: 'DuplicationTraceable::Course', inverse_of: :course, dependent: :destroy + has_many :scholaistic_assessments, through: :lesson_plan_items, source: :actable, + source_type: 'Course::ScholaisticAssessment' + accepts_nested_attributes_for :invitations, :assessment_categories, :video_tabs calculated :user_count, (lambda do diff --git a/app/models/course/condition.rb b/app/models/course/condition.rb index af831b7ebd1..c7a4bc342fe 100644 --- a/app/models/course/condition.rb +++ b/app/models/course/condition.rb @@ -24,7 +24,8 @@ class Course::Condition < ApplicationRecord { name: Course::Condition::Assessment.name, active: true }, { name: Course::Condition::Level.name, active: true }, { name: Course::Condition::Survey.name, active: true }, - { name: Course::Condition::Video.name, active: false } + { name: Course::Condition::Video.name, active: false }, + { name: Course::Condition::ScholaisticAssessment.name, active: true } ].freeze class << self diff --git a/app/models/course/condition/scholaistic_assessment.rb b/app/models/course/condition/scholaistic_assessment.rb new file mode 100644 index 00000000000..ec2e2edc6ad --- /dev/null +++ b/app/models/course/condition/scholaistic_assessment.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true +class Course::Condition::ScholaisticAssessment < ApplicationRecord + acts_as_condition + + validates :scholaistic_assessment, presence: true + validate :validate_scholaistic_assessment_condition, if: :scholaistic_assessment_id_changed? + + belongs_to :scholaistic_assessment, class_name: Course::ScholaisticAssessment.name, + inverse_of: :scholaistic_assessment_conditions + + default_scope { includes(:scholaistic_assessment) } + + alias_method :dependent_object, :scholaistic_assessment + + def title + self.class.human_attribute_name('title.complete', title: scholaistic_assessment.title) + end + + def satisfied_by?(course_user) + upstream_id = scholaistic_assessment.upstream_id + submissions = ScholaisticApiService.submissions!([upstream_id], course_user) + + submissions&.[](upstream_id)&.[](:status) == :graded + end + + def self.dependent_class + Course::ScholaisticAssessment.name + end + + def self.display_name(course) + course.settings(:course_scholaistic_component)&.assessments_title&.singularize + end + + private + + def validate_scholaistic_assessment_condition + validate_references_self + validate_unique_dependency + end + + def validate_references_self + return unless scholaistic_assessment == conditional + + errors.add(:scholaistic_assessment, :references_self) + end + + def validate_unique_dependency + return unless required_assessments_for(conditional).include?(scholaistic_assessment) + + errors.add(:scholaistic_assessment, :unique_dependency) + end + + def required_assessments_for(conditional) + Course::ScholaisticAssessment.joins(<<-SQL) + INNER JOIN + (SELECT cca.scholaistic_assessment_id + FROM course_condition_scholaistic_assessments cca INNER JOIN course_conditions cc + ON cc.actable_type = 'Course::Condition::ScholaisticAssessment' AND cc.actable_id = cca.id + WHERE cc.conditional_id = #{conditional.id} + AND cc.conditional_type = #{ActiveRecord::Base.connection.quote(conditional.class.name)} + ) ids + ON ids.scholaistic_assessment_id = course_scholaistic_assessments.id + SQL + end +end diff --git a/app/models/course/experience_points_record.rb b/app/models/course/experience_points_record.rb index 43ebcb44322..f9476f97db2 100644 --- a/app/models/course/experience_points_record.rb +++ b/app/models/course/experience_points_record.rb @@ -90,6 +90,8 @@ def validate_limit_exp_points_on_association survey = response.survey validate_lesson_plan_item_points(survey) + when Course::ScholaisticSubmission + validate_lesson_plan_item_points(specific.assessment) end end diff --git a/app/models/course/scholaistic_assessment.rb b/app/models/course/scholaistic_assessment.rb new file mode 100644 index 00000000000..15275e725b2 --- /dev/null +++ b/app/models/course/scholaistic_assessment.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +class Course::ScholaisticAssessment < ApplicationRecord + acts_as_lesson_plan_item + + validates :upstream_id, presence: true, uniqueness: { scope: :course_id } + validate :no_bonus_exp_attributes + + has_many :scholaistic_assessment_conditions, + class_name: Course::Condition::ScholaisticAssessment.name, + inverse_of: :scholaistic_assessment, dependent: :destroy + + has_many :submissions, + class_name: Course::ScholaisticSubmission.name, + inverse_of: :assessment, dependent: :destroy + + private + + # We don't allow Time Bonus EXPs for now because `start_at` and `end_at` are + # controlled on the ScholAIstic side. Supporting Time Bonus EXPs will be + # tricky if the `start_at` and `end_at` were set on ScholAIstic but Time + # Bonus EXPs are not synced properly on Coursemology. + def no_bonus_exp_attributes + return unless time_bonus_exp != 0 || bonus_end_at.present? + + errors.add(:time_bonus_exp, :bonus_attributes_not_allowed) + end + + # @override ConditionalInstanceMethods#permitted_for! + def permitted_for!(course_user) + end + + # @override ConditionalInstanceMethods#precluded_for! + def precluded_for!(course_user) + end + + # @override ConditionalInstanceMethods#satisfiable? + def satisfiable? + published? + end +end diff --git a/app/models/course/scholaistic_submission.rb b/app/models/course/scholaistic_submission.rb new file mode 100644 index 00000000000..a271aa1dd73 --- /dev/null +++ b/app/models/course/scholaistic_submission.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class Course::ScholaisticSubmission < ApplicationRecord + acts_as_experience_points_record + + validates :upstream_id, presence: true + validates :assessment, presence: true + validates :creator, presence: true + + belongs_to :assessment, inverse_of: :submissions, class_name: Course::ScholaisticAssessment.name +end diff --git a/app/models/course/settings/scholaistic_component.rb b/app/models/course/settings/scholaistic_component.rb new file mode 100644 index 00000000000..aad28fdd56d --- /dev/null +++ b/app/models/course/settings/scholaistic_component.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +class Course::Settings::ScholaisticComponent < Course::Settings::Component + include ActiveModel::Conversion + + def assessments_title + settings.assessments_title + end + + def assessments_title=(assessments_title) + settings.assessments_title = assessments_title.presence + end + + def integration_key + settings.integration_key + end + + def integration_key=(integration_key) + settings.integration_key = integration_key.presence + end + + def last_synced_at + settings.last_synced_at + end + + def last_synced_at=(last_synced_at) + settings.last_synced_at = last_synced_at.presence + end +end diff --git a/app/services/course/experience_points_download_service.rb b/app/services/course/experience_points_download_service.rb index 1761c58b476..c6ac02a2445 100644 --- a/app/services/course/experience_points_download_service.rb +++ b/app/services/course/experience_points_download_service.rb @@ -66,6 +66,8 @@ def download_exp_points(csv, record) record.specific.assessment.title when Course::Survey::Response record.specific.survey.title + when Course::ScholaisticSubmission # rubocop:disable Lint/DuplicateBranch + record.specific.assessment.title end end diff --git a/app/services/scholaistic_api_service.rb b/app/services/scholaistic_api_service.rb new file mode 100644 index 00000000000..7ac535e9891 --- /dev/null +++ b/app/services/scholaistic_api_service.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true +class ScholaisticApiService + class << self + def new_assessment_path + '/administration/assessments/new' + end + + def edit_assessment_details_path(assessment_id) + "/administration/assessments/#{assessment_id}/details" + end + + def edit_assessment_path(assessment_id) + "/administration/assessments/#{assessment_id}" + end + + def assessment_path(assessment_id) + "/assessments/#{assessment_id}" + end + + def submissions_path(assessment_id) + "/administration/assessments/#{assessment_id}/submissions" + end + + def manage_submission_path(assessment_id, submission_id) + "/administration/assessments/#{assessment_id}/submissions/#{submission_id}" + end + + def submission_path(assessment_id, submission_id) + "/assessments/#{assessment_id}/submissions/#{submission_id}" + end + + def assistants_path + '/administration/assistants' + end + + def assistant_path(assistant_id) + "/assistants/#{assistant_id}" + end + + def embed!(course_user, path, origin) + connection!(:post, 'embed', body: { + key: settings(course_user.course).integration_key, + path: path, + origin: origin, + upsert_course_user: course_user_upsert_payload(course_user) + }) + end + + def assistant!(course, assistant_id) + result = connection!(:get, 'assistant', query: { key: settings(course).integration_key, id: assistant_id }) + + { title: result[:title] } + end + + def assistants!(course) + result = connection!(:get, 'assistants', query: { key: settings(course).integration_key }) + + result.filter_map do |assistant| + next if assistant[:activityType] != 'assistant' || !assistant[:isPublished] + + { + id: assistant[:id], + title: assistant[:title], + sidebar_title: assistant[:altTitle] + } + end + end + + def find_or_create_submission!(course_user, assessment_id) + result = connection!(:post, 'submission', body: { + key: settings(course_user.course).integration_key, + assessment_id: assessment_id, + upsert_course_user: course_user_upsert_payload(course_user) + }) + + result[:id] + end + + def submission!(course, submission_id) + result = connection!(:get, 'submission', query: { + key: settings(course).integration_key, + id: submission_id + }) + + { + creator_name: result[:creatorName], + creator_email: result[:creatorEmail], + status: result[:status]&.to_sym + } + rescue Excon::Error::NotFound + { status: :not_found } + end + + def submissions!(assessment_ids, course_user) + result = connection!(:post, 'submissions', body: { + key: settings(course_user.course).integration_key, + assessment_ids: assessment_ids, + upsert_course_user: course_user_upsert_payload(course_user) + }) + + result.to_h do |assessment_id, submission| + [assessment_id.to_s, + status: submission[:status]&.to_sym, + id: submission[:submissionId]] + end + end + + def assessments!(course) + result = connection!(:get, 'assessments', query: { + key: settings(course).integration_key, + lastSynced: settings(course).last_synced_at + }.compact) + + { + assessments: result[:assessments].filter_map do |assessment| + next if assessment[:activityType] != 'assessment' + + { + upstream_id: assessment[:id], + published: assessment[:isPublished], + title: assessment[:title], + description: assessment[:description], + start_at: assessment[:startsAt], + end_at: assessment[:endsAt] + } + end, + deleted: result[:deleted], + last_synced_at: result[:lastSynced] + } + end + + def ping_course(key) + response = connection!(:get, 'course-link', query: { key: key }) + + { status: :ok, title: response&.[](:title), url: response&.[](:url) } + rescue StandardError => e + Rails.logger.error("Failed to ping Scholaistic course: #{e.message}") + raise e unless Rails.env.production? + + { status: :error } + end + + def unlink_course!(key) + connection!(:delete, 'course-link', query: { key: key }) + end + + def link_course_url!(options) + payload = { + rq: REQUESTER_PLATFORM_NAME, + ex: COURSE_LINKING_EXPIRY.from_now.to_i, + ap: api_key, + rn: options[:course_title], + ru: options[:course_url], + cu: options[:callback_url] + } + + public_key_string = connection!(:get, 'public-key') + public_key = OpenSSL::PKey::RSA.new(public_key_string) + encrypted_payload = Base64.encode64(public_key.public_encrypt(payload.to_json)) + + URI.parse("#{base_url}/link-course").tap do |uri| + uri.query = URI.encode_www_form(p: encrypted_payload) + end.to_s + end + + def parse_link_course_callback_request(request, params) + scheme, request_api_key = request.headers['Authorization']&.split + return nil unless scheme == 'Bearer' && request_api_key == api_key + + params.require(:key) + end + + private + + REQUESTER_PLATFORM_NAME = 'Coursemology' + COURSE_LINKING_EXPIRY = 10.minutes + + DEFAULT_REQUEST_TIMEOUT_SECONDS = 5 + + def connection!(method, path, options = {}) + api_base_url = ENV.fetch('SCHOLAISTIC_API_BASE_URL') + + connection = Excon.new( + "#{api_base_url}/#{path}", + headers: { Authorization: "Bearer #{api_key}" }, + method: method, + timeout: DEFAULT_REQUEST_TIMEOUT_SECONDS, + **options, + body: options[:body]&.to_json, + expects: [200, 201, 204] + ) + + body = JSON.parse(connection.request.body, symbolize_names: true) + + body&.[](:payload)&.[](:data) + rescue JSON::ParserError => e + Rails.logger.error("Failed to parse JSON response from Scholaistic API: #{e.message}") + raise e unless Rails.env.production? + + nil + end + + def base_url + ENV.fetch('SCHOLAISTIC_BASE_URL') + end + + def api_key + ENV.fetch('SCHOLAISTIC_API_KEY') + end + + def settings(course) + course.settings(:course_scholaistic_component) + end + + def scholaistic_course_user_role(course_user) + return 'owner' if course_user.manager_or_owner? + return 'manager' if course_user.staff? + + 'student' + end + + def course_user_upsert_payload(course_user) + { + name: course_user.name, + email: course_user.user.email, + role: scholaistic_course_user_role(course_user) + } + end + end +end diff --git a/app/views/course/admin/scholaistic_settings/edit.json.jbuilder b/app/views/course/admin/scholaistic_settings/edit.json.jbuilder new file mode 100644 index 00000000000..e43a7a91124 --- /dev/null +++ b/app/views/course/admin/scholaistic_settings/edit.json.jbuilder @@ -0,0 +1,10 @@ +# frozen_string_literal: true +json.assessmentsTitle @settings.assessments_title + +if @ping_result + json.pingResult do + json.status @ping_result[:status] + json.title @ping_result[:title] + json.url @ping_result[:url] + end +end diff --git a/app/views/course/condition/_conditions.json.jbuilder b/app/views/course/condition/_conditions.json.jbuilder index 089a57cd135..d5cfe87bb34 100644 --- a/app/views/course/condition/_conditions.json.jbuilder +++ b/app/views/course/condition/_conditions.json.jbuilder @@ -2,6 +2,7 @@ json.array! conditional.specific_conditions do |condition| json.partial! 'course/condition/condition_list_data', condition: condition json.partial! condition.to_partial_path, condition: condition - json.type condition.model_name.human + json.type condition.class.model_name.element + json.displayName condition.class.display_name(current_course) json.url url_for([current_course, conditional, condition]) end diff --git a/app/views/course/condition/_enabled_conditions.json.jbuilder b/app/views/course/condition/_enabled_conditions.json.jbuilder index 72655590eeb..90806128929 100644 --- a/app/views/course/condition/_enabled_conditions.json.jbuilder +++ b/app/views/course/condition/_enabled_conditions.json.jbuilder @@ -2,7 +2,8 @@ json.enabledConditions Course::Condition::ALL_CONDITIONS do |condition| if component_enabled?(condition[:name]) && condition[:active] condition_model = condition[:name].constantize - json.type condition_model.model_name.human + json.type condition_model.model_name.element + json.displayName condition_model.display_name(current_course) json.url url_for([current_course, conditional, condition_model]) end end diff --git a/app/views/course/condition/scholaistic_assessments/_scholaistic_assessment.json.jbuilder b/app/views/course/condition/scholaistic_assessments/_scholaistic_assessment.json.jbuilder new file mode 100644 index 00000000000..a5db430bf61 --- /dev/null +++ b/app/views/course/condition/scholaistic_assessments/_scholaistic_assessment.json.jbuilder @@ -0,0 +1,2 @@ +# frozen_string_literal: true +json.assessmentId condition.scholaistic_assessment_id diff --git a/app/views/course/condition/scholaistic_assessments/available_scholaistic_assessments.json.jbuilder b/app/views/course/condition/scholaistic_assessments/available_scholaistic_assessments.json.jbuilder new file mode 100644 index 00000000000..1e465bac821 --- /dev/null +++ b/app/views/course/condition/scholaistic_assessments/available_scholaistic_assessments.json.jbuilder @@ -0,0 +1,11 @@ +# frozen_string_literal: true +json.ids @available_assessments.map(&:id) + +json.assessments do + @available_assessments.each do |assessment| + json.set! assessment.id, { + title: assessment.title, + url: course_scholaistic_assessment_path(current_course, assessment) + } + end +end diff --git a/app/views/course/courses/_sidebar_items.json.jbuilder b/app/views/course/courses/_sidebar_items.json.jbuilder index 61530207369..a24411dfbf9 100644 --- a/app/views/course/courses/_sidebar_items.json.jbuilder +++ b/app/views/course/courses/_sidebar_items.json.jbuilder @@ -5,4 +5,5 @@ json.array! items do |item| json.path item[:path] json.icon item[:icon] json.unread item[:unread] if item[:unread]&.nonzero? + json.exact item[:exact].presence end diff --git a/app/views/course/experience_points_records/_experience_points_record.json.jbuilder b/app/views/course/experience_points_records/_experience_points_record.json.jbuilder index 9655a993e0b..6c0e6b1b35f 100644 --- a/app/views/course/experience_points_records/_experience_points_record.json.jbuilder +++ b/app/views/course/experience_points_records/_experience_points_record.json.jbuilder @@ -38,6 +38,16 @@ json.reason do else json.link course_survey_responses_path(course, survey) end + when Course::ScholaisticSubmission + submission = specific + scholaistic_assessment = submission.assessment + json.maxExp scholaistic_assessment.base_exp + json.text scholaistic_assessment.title + if can?(:read, scholaistic_assessment) + json.link course_scholaistic_assessment_submission_path(course, scholaistic_assessment, submission.upstream_id) + else + json.link course_scholaistic_assessment_submissions_path(course, scholaistic_assessment) + end end end end diff --git a/app/views/course/scholaistic/assistants/index.json.jbuilder b/app/views/course/scholaistic/assistants/index.json.jbuilder new file mode 100644 index 00000000000..043aa0d4f4b --- /dev/null +++ b/app/views/course/scholaistic/assistants/index.json.jbuilder @@ -0,0 +1,2 @@ +# frozen_string_literal: true +json.embedSrc @embed_src diff --git a/app/views/course/scholaistic/assistants/show.json.jbuilder b/app/views/course/scholaistic/assistants/show.json.jbuilder new file mode 100644 index 00000000000..5d236bff553 --- /dev/null +++ b/app/views/course/scholaistic/assistants/show.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true +json.embedSrc @embed_src + +json.display do + json.assistantTitle @assistant_title +end diff --git a/app/views/course/scholaistic/scholaistic_assessments/edit.json.jbuilder b/app/views/course/scholaistic/scholaistic_assessments/edit.json.jbuilder new file mode 100644 index 00000000000..3e7746dd26e --- /dev/null +++ b/app/views/course/scholaistic/scholaistic_assessments/edit.json.jbuilder @@ -0,0 +1,12 @@ +# frozen_string_literal: true +json.embedSrc @embed_src + +json.assessment do + json.baseExp @scholaistic_assessment.base_exp if current_course.gamified? +end + +json.display do + json.assessmentTitle @scholaistic_assessment.title + json.isGamified current_course.gamified? + json.assessmentsTitle current_course.settings(:course_scholaistic_component)&.assessments_title +end diff --git a/app/views/course/scholaistic/scholaistic_assessments/index.json.jbuilder b/app/views/course/scholaistic/scholaistic_assessments/index.json.jbuilder new file mode 100644 index 00000000000..17c6592c31b --- /dev/null +++ b/app/views/course/scholaistic/scholaistic_assessments/index.json.jbuilder @@ -0,0 +1,21 @@ +# frozen_string_literal: true +json.assessments @scholaistic_assessments do |scholaistic_assessment| + json.id scholaistic_assessment.id + json.title scholaistic_assessment.title + json.startAt scholaistic_assessment.start_at + json.endAt scholaistic_assessment.end_at + json.published scholaistic_assessment.published? + json.isStartTimeBegin scholaistic_assessment.start_at <= Time.zone.now + json.isEndTimePassed scholaistic_assessment.end_at.present? && scholaistic_assessment.end_at < Time.zone.now + json.status @assessments_status[scholaistic_assessment.id] + json.baseExp scholaistic_assessment.base_exp if current_course.gamified? && (scholaistic_assessment.base_exp > 0) +end + +json.display do + json.assessmentsTitle current_course.settings(:course_scholaistic_component).assessments_title + json.isStudent current_course_user&.student? || false + json.isGamified current_course.gamified? + json.canEditAssessments can?(:edit, Course::ScholaisticAssessment.new(course: current_course)) + json.canCreateAssessments can?(:create, Course::ScholaisticAssessment.new(course: current_course)) + json.canViewSubmissions can?(:view_submissions, Course::ScholaisticAssessment.new(course: current_course)) +end diff --git a/app/views/course/scholaistic/scholaistic_assessments/new.json.jbuilder b/app/views/course/scholaistic/scholaistic_assessments/new.json.jbuilder new file mode 100644 index 00000000000..f1bb7d7ad22 --- /dev/null +++ b/app/views/course/scholaistic/scholaistic_assessments/new.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true +json.embedSrc @embed_src + +json.display do + json.assessmentsTitle current_course.settings(:course_scholaistic_component)&.assessments_title +end diff --git a/app/views/course/scholaistic/scholaistic_assessments/show.json.jbuilder b/app/views/course/scholaistic/scholaistic_assessments/show.json.jbuilder new file mode 100644 index 00000000000..1df720d49ce --- /dev/null +++ b/app/views/course/scholaistic/scholaistic_assessments/show.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true +json.embedSrc @embed_src + +json.display do + json.assessmentTitle @scholaistic_assessment.title + json.assessmentsTitle current_course.settings(:course_scholaistic_component)&.assessments_title +end diff --git a/app/views/course/scholaistic/submissions/index.json.jbuilder b/app/views/course/scholaistic/submissions/index.json.jbuilder new file mode 100644 index 00000000000..1df720d49ce --- /dev/null +++ b/app/views/course/scholaistic/submissions/index.json.jbuilder @@ -0,0 +1,7 @@ +# frozen_string_literal: true +json.embedSrc @embed_src + +json.display do + json.assessmentTitle @scholaistic_assessment.title + json.assessmentsTitle current_course.settings(:course_scholaistic_component)&.assessments_title +end diff --git a/app/views/course/scholaistic/submissions/show.json.jbuilder b/app/views/course/scholaistic/submissions/show.json.jbuilder new file mode 100644 index 00000000000..770d046bd08 --- /dev/null +++ b/app/views/course/scholaistic/submissions/show.json.jbuilder @@ -0,0 +1,8 @@ +# frozen_string_literal: true +json.embedSrc @embed_src + +json.display do + json.assessmentTitle @scholaistic_assessment.title + json.creatorName @creator_name + json.assessmentsTitle current_course.settings(:course_scholaistic_component)&.assessments_title +end diff --git a/client/.babelrc b/client/.babelrc index 1c9ea75a9bb..417ecc27379 100644 --- a/client/.babelrc +++ b/client/.babelrc @@ -11,7 +11,8 @@ "formatjs", { "idInterpolationPattern": "[sha512:contenthash:base64:6]", - "ast": true + "ast": true, + "additionalFunctionNames": ["t"] } ], [ diff --git a/client/app/api/course/Admin/Scholaistic.ts b/client/app/api/course/Admin/Scholaistic.ts new file mode 100644 index 00000000000..42d0ebd69fd --- /dev/null +++ b/client/app/api/course/Admin/Scholaistic.ts @@ -0,0 +1,32 @@ +import type { + ScholaisticSettingsData, + ScholaisticSettingsPostData, +} from 'types/course/admin/scholaistic'; + +import { APIResponse, JustRedirect } from 'api/types'; + +import BaseAdminAPI from './Base'; + +export default class ScholaisticAdminAPI extends BaseAdminAPI { + override get urlPrefix(): string { + return `${super.urlPrefix}/scholaistic`; + } + + index(): APIResponse { + return this.client.get(this.urlPrefix); + } + + update( + data: ScholaisticSettingsPostData, + ): APIResponse { + return this.client.patch(this.urlPrefix, data); + } + + getLinkScholaisticCourseUrl(): APIResponse { + return this.client.get(`${this.urlPrefix}/link_course`); + } + + unlinkScholaisticCourse(): APIResponse { + return this.client.post(`${this.urlPrefix}/unlink_course`); + } +} diff --git a/client/app/api/course/Admin/index.ts b/client/app/api/course/Admin/index.ts index 8625552c734..966a1d3b05f 100644 --- a/client/app/api/course/Admin/index.ts +++ b/client/app/api/course/Admin/index.ts @@ -11,6 +11,7 @@ import LessonPlanSettingsAPI from './LessonPlan'; import MaterialsAdminAPI from './Materials'; import NotificationsSettingsAPI from './Notifications'; import RagWiseAdminAPI from './RagWise'; +import ScholaisticAdminAPI from './Scholaistic'; import SidebarAPI from './Sidebar'; import StoriesAdminAPI from './Stories'; import VideosAdminAPI from './Videos'; @@ -30,6 +31,7 @@ const AdminAPI = { videos: new VideosAdminAPI(), notifications: new NotificationsSettingsAPI(), codaveri: new CodaveriAdminAPI(), + scholaistic: new ScholaisticAdminAPI(), stories: new StoriesAdminAPI(), ragWise: new RagWiseAdminAPI(), }; diff --git a/client/app/api/course/Conditions.ts b/client/app/api/course/Conditions.ts index a8235979d59..cdc57b9d385 100644 --- a/client/app/api/course/Conditions.ts +++ b/client/app/api/course/Conditions.ts @@ -1,6 +1,7 @@ import { AvailableAchievements, AvailableAssessments, + AvailableScholaisticAssessments, AvailableSurveys, ConditionAbility, ConditionData, @@ -45,4 +46,10 @@ export default class ConditionsAPI extends BaseCourseAPI { fetchSurveys(url: ConditionAbility['url']): APIResponse { return this.client.get(url); } + + fetchScholaisticAssessments( + url: ConditionAbility['url'], + ): APIResponse { + return this.client.get(url); + } } diff --git a/client/app/api/course/Scholaistic.ts b/client/app/api/course/Scholaistic.ts new file mode 100644 index 00000000000..1d02401902d --- /dev/null +++ b/client/app/api/course/Scholaistic.ts @@ -0,0 +1,86 @@ +import { + ScholaisticAssessmentEditData, + ScholaisticAssessmentNewData, + ScholaisticAssessmentsIndexData, + ScholaisticAssessmentSubmissionEditData, + ScholaisticAssessmentSubmissionsIndexData, + ScholaisticAssessmentUpdatePostData, + ScholaisticAssessmentViewData, + ScholaisticAssistantEditData, + ScholaisticAssistantsIndexData, +} from 'types/course/scholaistic'; + +import { APIResponse } from 'api/types'; + +import BaseCourseAPI from './Base'; + +export default class ScholaisticAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/scholaistic`; + } + + fetchAssessments(): APIResponse { + return this.client.get(`${this.#urlPrefix}/assessments`); + } + + fetchAssessment( + assessmentId: number, + ): APIResponse { + return this.client.get(`${this.#urlPrefix}/assessments/${assessmentId}`); + } + + updateAssessment( + assessmentId: number, + data: ScholaisticAssessmentUpdatePostData, + ): APIResponse { + return this.client.patch( + `${this.#urlPrefix}/assessments/${assessmentId}`, + data, + ); + } + + fetchEditAssessment( + assessmentId: number, + ): APIResponse { + return this.client.get( + `${this.#urlPrefix}/assessments/${assessmentId}/edit`, + ); + } + + fetchNewAssessment(): APIResponse { + return this.client.get(`${this.#urlPrefix}/assessments/new`); + } + + fetchSubmissions( + assessmentId: number, + ): APIResponse { + return this.client.get( + `${this.#urlPrefix}/assessments/${assessmentId}/submissions`, + ); + } + + fetchSubmission( + assessmentId: number, + submissionId: string, + ): APIResponse { + return this.client.get( + `${this.#urlPrefix}/assessments/${assessmentId}/submissions/${submissionId}`, + ); + } + + findOrCreateSubmission(assessmentId: number): APIResponse<{ id: string }> { + return this.client.get( + `${this.#urlPrefix}/assessments/${assessmentId}/submission`, + ); + } + + fetchAssistants(): APIResponse { + return this.client.get(`${this.#urlPrefix}/assistants`); + } + + fetchAssistant( + assistantId: string, + ): APIResponse { + return this.client.get(`${this.#urlPrefix}/assistants/${assistantId}`); + } +} diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 3cfe4141a2a..eb95733252b 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -22,6 +22,7 @@ import MaterialsAPI from './Materials'; import PersonalTimesAPI from './PersonalTimes'; import PlagiarismAPI from './Plagiarism'; import ReferenceTimelinesAPI from './ReferenceTimelines'; +import ScholaisticAPI from './Scholaistic'; import StatisticsAPI from './Statistics'; import StoriesAPI from './Stories'; import SurveyAPI from './Survey'; @@ -66,6 +67,7 @@ const CourseAPI = { userEmailSubscriptions: new UserEmailSubscriptionsAPI(), userNotifications: new UserNotificationsAPI(), stories: new StoriesAPI(), + scholaistic: new ScholaisticAPI(), }; Object.freeze(CourseAPI); diff --git a/client/app/bundles/course/admin/pages/ScholaisticSettings/PingResultAlert.tsx b/client/app/bundles/course/admin/pages/ScholaisticSettings/PingResultAlert.tsx new file mode 100644 index 00000000000..b66d168c475 --- /dev/null +++ b/client/app/bundles/course/admin/pages/ScholaisticSettings/PingResultAlert.tsx @@ -0,0 +1,43 @@ +import { Alert } from '@mui/material'; +import { ScholaisticSettingsData } from 'types/course/admin/scholaistic'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +const PingResultAlert = ({ + result, +}: { + result: ScholaisticSettingsData['pingResult']; +}): JSX.Element => { + const { t } = useTranslation(); + + if (result.status === 'error') + return ( + + {t({ + defaultMessage: + "This course's link to ScholAIstic can't be verified. Either ScholAIstic is not reachable at the moment, or the link is invalid. Try again later, or try relinking the courses again.", + })} + + ); + + return ( + + {t( + { + defaultMessage: + 'This course is linked to the {course} course on ScholAIstic.', + }, + { + course: ( + + {result.title} + + ), + }, + )} + + ); +}; + +export default PingResultAlert; diff --git a/client/app/bundles/course/admin/pages/ScholaisticSettings/index.tsx b/client/app/bundles/course/admin/pages/ScholaisticSettings/index.tsx new file mode 100644 index 00000000000..91333f7a3e3 --- /dev/null +++ b/client/app/bundles/course/admin/pages/ScholaisticSettings/index.tsx @@ -0,0 +1,292 @@ +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { Controller } from 'react-hook-form'; +import { + FaceOutlined, + SmartToyOutlined, + SupervisorAccountOutlined, + SvgIconComponent, +} from '@mui/icons-material'; +import { Button, Typography } from '@mui/material'; +import { ScholaisticSettingsData } from 'types/course/admin/scholaistic'; + +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import Section from 'lib/components/core/layouts/Section'; +import Link from 'lib/components/core/Link'; +import FormTextField from 'lib/components/form/fields/TextField'; +import Form, { FormRef } from 'lib/components/form/Form'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; +import formTranslations from 'lib/translations/form'; + +import { useLoader } from './loader'; +import { + getLinkScholaisticCourseUrl, + unlinkScholaisticCourse, + updateScholaisticSettings, +} from './operations'; +import PingResultAlert from './PingResultAlert'; + +const IntroductionItem = ({ + Icon, + children, +}: { + Icon: SvgIconComponent; + children?: ReactNode; +}): JSX.Element => { + return ( +
+
+ +
+ +
+ {children} +
+
+ ); +}; + +const ScholaisticSettings = (): JSX.Element => { + const { t } = useTranslation(); + + const [submitting, setSubmitting] = useState(false); + const [confirmUnlinkPromptOpen, setConfirmUnlinkPromptOpen] = useState(false); + const [linkingOrigin, setLinkingOrigin] = useState(); + + useEffect(() => { + if (!linkingOrigin) return () => {}; + + const handleLinked = (event: MessageEvent<{ type: 'linked' }>): void => { + if (event.origin !== linkingOrigin || event.data?.type !== 'linked') + return; + + window.focus(); + window.location.reload(); + }; + + window.addEventListener('message', handleLinked); + + return () => { + window.removeEventListener('message', handleLinked); + }; + }, [linkingOrigin]); + + const formRef = useRef>(null); + + const initialValues = useLoader(); + + const handleSubmit = useCallback((data: ScholaisticSettingsData): void => { + setSubmitting(true); + + updateScholaisticSettings(data) + .then((newData) => { + if (!newData) return; + + formRef.current?.resetTo?.(newData); + toast.success(t(formTranslations.changesSaved)); + }) + .catch(formRef.current?.receiveErrors) + .finally(() => setSubmitting(false)); + }, []); + + const handleLinkCourse = useCallback((): void => { + setSubmitting(true); + + getLinkScholaisticCourseUrl() + .then((url) => { + setLinkingOrigin(new URL(url).origin); + window.open(url, '_blank'); + }) + .catch((error) => { + console.error(error); + + toast.error( + t({ + defaultMessage: + 'Something went wrong when requesting a course link from ScholAIstic.', + }), + ); + }) + .finally(() => setSubmitting(false)); + }, []); + + const handleUnlinkCourses = useCallback((): void => { + setSubmitting(true); + + unlinkScholaisticCourse() + .then(() => { + toast.success(t({ defaultMessage: 'The courses have been unlinked.' })); + window.location.reload(); + }) + .catch((error) => { + setSubmitting(false); + console.error(error); + + toast.error( + t({ + defaultMessage: 'Something went wrong when unlinking the courses.', + }), + ); + }); + }, []); + + return ( +
+
+ {(control) => ( +
+ ( + + )} + /> +
+ )} +
+ +
+
+ + {t( + { + defaultMessage: + "This feature is powered by ScholAIstic. To begin using this feature, you'll need to link a course in ScholAIstic with this course. Here's what's going to happen once both courses are linked.", + }, + { + link: (chunk) => ( + + {chunk} + + ), + }, + )} + + +
+ + {t({ + defaultMessage: + "You'll be able to create role-playing chatbots and assessments in this course. The published ones will be available to your students.", + })} + + + + {t({ + defaultMessage: + 'Only you, Owners, and Managers can configure the link of this course with ScholAIstic. The courses can be unlinked at any time.', + })} + + + + {t( + { + defaultMessage: + "User accounts on ScholAIstic will automatically be created if they don't yet exist. Information shared with ScholAIstic is governed by our Privacy Policy and ScholAIstic's Terms of Service.", + }, + { + ourPpLink: (chunk) => ( + + {chunk} + + ), + scholaisticTosLink: (chunk) => ( + + {chunk} + + ), + }, + )} + +
+
+ + {!initialValues.pingResult && ( + + )} + + {initialValues.pingResult && ( + <> + + + + + setConfirmUnlinkPromptOpen(false)} + open={confirmUnlinkPromptOpen} + primaryColor="error" + primaryLabel={t({ defaultMessage: 'Unlink these courses' })} + title={t({ + defaultMessage: "Sure you're unlinking these courses?", + })} + > + + {t({ + defaultMessage: + 'Once you unlink these courses, users in this course will no longer be able to access the role-playing chatbots and assessments in the linked ScholAIstic course.', + })} + + + + {t({ + defaultMessage: + 'No user data will be deleted. You can link these courses again at any time.', + })} + + + + )} +
+
+ ); +}; + +export default ScholaisticSettings; diff --git a/client/app/bundles/course/admin/pages/ScholaisticSettings/loader.ts b/client/app/bundles/course/admin/pages/ScholaisticSettings/loader.ts new file mode 100644 index 00000000000..346c31fca3f --- /dev/null +++ b/client/app/bundles/course/admin/pages/ScholaisticSettings/loader.ts @@ -0,0 +1,9 @@ +import { LoaderFunction, useLoaderData } from 'react-router-dom'; +import { ScholaisticSettingsData } from 'types/course/admin/scholaistic'; + +import { fetchScholaisticSettings } from './operations'; + +export const loader: LoaderFunction = async () => fetchScholaisticSettings(); + +export const useLoader = (): ScholaisticSettingsData => + useLoaderData() as ScholaisticSettingsData; diff --git a/client/app/bundles/course/admin/pages/ScholaisticSettings/operations.ts b/client/app/bundles/course/admin/pages/ScholaisticSettings/operations.ts new file mode 100644 index 00000000000..f37e6e6dad0 --- /dev/null +++ b/client/app/bundles/course/admin/pages/ScholaisticSettings/operations.ts @@ -0,0 +1,43 @@ +import { AxiosError } from 'axios'; +import { ScholaisticSettingsData } from 'types/course/admin/scholaistic'; + +import CourseAPI from 'api/course'; + +export const fetchScholaisticSettings = + async (): Promise => { + const response = await CourseAPI.admin.scholaistic.index(); + return response.data; + }; + +export const updateScholaisticSettings = async ( + data: ScholaisticSettingsData, +): Promise => { + try { + const response = await CourseAPI.admin.scholaistic.update({ + settings_scholaistic_component: { + assessments_title: data.assessmentsTitle, + }, + }); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } +}; + +export const getLinkScholaisticCourseUrl = async (): Promise => { + const response = + await CourseAPI.admin.scholaistic.getLinkScholaisticCourseUrl(); + return response.data.redirectUrl; +}; + +export const unlinkScholaisticCourse = async (): Promise => { + try { + const response = + await CourseAPI.admin.scholaistic.unlinkScholaisticCourse(); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageOptionHistory.tsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageOptionHistory.tsx index 4eb328d386f..d530696316b 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageOptionHistory.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/LiveFeedbackHistory/LiveFeedbackMessageOptionHistory.tsx @@ -36,10 +36,7 @@ const LiveFeedbackMessageOptionHistory: FC = (props) => { disabled variant="outlined" > - {t({ - id: optionDetail.id, - defaultMessage: optionDetail.defaultMessage, - })} + {t(optionDetail)} ); })} diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/ActionButtons.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ActionButtons.tsx index 22c96de530c..add5e205c21 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/ActionButtons.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ActionButtons.tsx @@ -82,7 +82,13 @@ const ActionButtons = (props: ActionButtonsProps): JSX.Element => { )} {assessment.status === 'unavailable' && ( - + )} {student && assessment.status === 'locked' && ( diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/UnavailableMessage.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/UnavailableMessage.tsx index b0143021c11..88a6d721809 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/UnavailableMessage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/UnavailableMessage.tsx @@ -1,10 +1,7 @@ import { ReactNode } from 'react'; import { Lock } from '@mui/icons-material'; import { Tooltip, Typography } from '@mui/material'; -import { - AssessmentListData, - AssessmentUnlockRequirements, -} from 'types/course/assessment/assessments'; +import { AssessmentUnlockRequirements } from 'types/course/assessment/assessments'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; @@ -13,10 +10,6 @@ import useTranslation from 'lib/hooks/useTranslation'; import { fetchAssessmentUnlockRequirements } from '../../operations/assessments'; import translations from '../../translations'; -interface UnavailableMessageProps { - for: AssessmentListData; -} - const ShakyLock = ({ title }: { title: string | ReactNode }): JSX.Element => (
@@ -28,16 +21,22 @@ const ShakyLock = ({ title }: { title: string | ReactNode }): JSX.Element => (
); -const UnavailableMessage = ( - props: UnavailableMessageProps, -): JSX.Element | null => { - const { for: assessment } = props; +const UnavailableMessage = ({ + isStartTimeBegin, + hasConditions, +}: { + isStartTimeBegin?: boolean; + hasConditions?: { + conditionSatisfied: boolean; + assessmentId: number; + }; +}): JSX.Element | null => { const { t } = useTranslation(); - if (!assessment.isStartTimeBegin) + if (!isStartTimeBegin) return ; - if (!assessment.conditionSatisfied) + if (hasConditions && !hasConditions.conditionSatisfied) return ( } while={(): Promise => - fetchAssessmentUnlockRequirements(assessment.id) + fetchAssessmentUnlockRequirements(hasConditions.assessmentId) } > {(data): JSX.Element => ( diff --git a/client/app/bundles/course/assessment/submission/components/GetHelpChatPage/SuggestionChips.tsx b/client/app/bundles/course/assessment/submission/components/GetHelpChatPage/SuggestionChips.tsx index 6d445329776..99951b14183 100644 --- a/client/app/bundles/course/assessment/submission/components/GetHelpChatPage/SuggestionChips.tsx +++ b/client/app/bundles/course/assessment/submission/components/GetHelpChatPage/SuggestionChips.tsx @@ -35,10 +35,7 @@ const SuggestionChips: FC = (props) => { const suggestions = liveFeedbackChatsForAnswer?.suggestions ?? []; const sendHelpRequest = (suggestion: Suggestion): void => { - const message = t({ - id: suggestion.id, - defaultMessage: suggestion.defaultMessage, - }); + const message = t(suggestion); dispatch(sendPromptFromStudent({ answerId, message })); dispatch( @@ -64,10 +61,7 @@ const SuggestionChips: FC = (props) => { onClick={() => sendHelpRequest(suggestion)} variant="outlined" > - {t({ - id: suggestion.id, - defaultMessage: suggestion.defaultMessage, - })} + {t(suggestion)} ))} diff --git a/client/app/bundles/course/container/Sidebar/SidebarItem.tsx b/client/app/bundles/course/container/Sidebar/SidebarItem.tsx index 04592e4462d..ea8edc51deb 100644 --- a/client/app/bundles/course/container/Sidebar/SidebarItem.tsx +++ b/client/app/bundles/course/container/Sidebar/SidebarItem.tsx @@ -21,9 +21,10 @@ const SidebarItem = (props: SidebarItemProps): JSX.Element => { const location = useLocation(); const activeUrl = activePath ?? location.pathname + location.search; - const isActive = exact - ? activeUrl === item.path - : activeUrl.startsWith(item.path); + const isActive = + exact || item.exact + ? activeUrl === item.path + : activeUrl.startsWith(item.path); const Icon = defensivelyGetIcon(item.icon, isActive ? 'filled' : 'outlined'); diff --git a/client/app/bundles/course/scholaistic/components/ScholaisticAsyncContainer.tsx b/client/app/bundles/course/scholaistic/components/ScholaisticAsyncContainer.tsx new file mode 100644 index 00000000000..2b23c52c867 --- /dev/null +++ b/client/app/bundles/course/scholaistic/components/ScholaisticAsyncContainer.tsx @@ -0,0 +1,34 @@ +import { ComponentType, ReactNode, Suspense } from 'react'; +import { Await, useLoaderData } from 'react-router-dom'; + +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; + +const ScholaisticAsyncContainer = ({ + children, +}: { + children: ReactNode; +}): JSX.Element => { + const data = useLoaderData() as { promise: Promise }; + + return ( + }> + oopsie

} resolve={data.promise}> + {children} +
+
+ ); +}; + +export const withScholaisticAsyncContainer = ( + Component: ComponentType, +): ComponentType => { + const WrappedComponent: ComponentType = (props) => ( + + + + ); + + WrappedComponent.displayName = `withScholaisticAsyncContainer(${Component.displayName || Component.name})`; + + return WrappedComponent; +}; diff --git a/client/app/bundles/course/scholaistic/components/ScholaisticErrorPage.tsx b/client/app/bundles/course/scholaistic/components/ScholaisticErrorPage.tsx new file mode 100644 index 00000000000..44b4cf882bf --- /dev/null +++ b/client/app/bundles/course/scholaistic/components/ScholaisticErrorPage.tsx @@ -0,0 +1,58 @@ +import { Cancel, SmartToy } from '@mui/icons-material'; +import { Typography } from '@mui/material'; + +import Page from 'lib/components/core/layouts/Page'; +import Link from 'lib/components/core/Link'; +import { SUPPORT_EMAIL } from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; + +const ScholaisticErrorPage = (): JSX.Element => { + const { t } = useTranslation(); + + return ( + +
+ + + +
+ + + {t({ + defaultMessage: + "Either it's supposed to be naught, or something went wrong.", + })} + + + + {t( + { + defaultMessage: + "ScholAIstic is our partner that powers this experience. They were contactable, but didn't give us anything for this request just now. Please try again later, and if this persists, contact us.", + }, + { + scholaistic: (chunk) => ( + + {chunk} + + ), + contact: (chunk) => ( + + {chunk} + + ), + }, + )} + +
+ ); +}; + +export default ScholaisticErrorPage; diff --git a/client/app/bundles/course/scholaistic/components/ScholaisticFramePage.tsx b/client/app/bundles/course/scholaistic/components/ScholaisticFramePage.tsx new file mode 100644 index 00000000000..6f1459c3145 --- /dev/null +++ b/client/app/bundles/course/scholaistic/components/ScholaisticFramePage.tsx @@ -0,0 +1,103 @@ +import { useEffect, useMemo } from 'react'; +import { Alert } from '@mui/material'; +import { cn } from 'utilities'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +const ScholaisticFramePage = ({ + src, + framed, + onMessage, +}: { + src: string; + framed?: boolean; + onMessage?: (data: { type: string; payload: unknown }) => void; +}): JSX.Element => { + const { t } = useTranslation(); + + useEffect(() => { + if (!onMessage) return () => {}; + + const handleMessage = ({ + origin, + data, + }: MessageEvent<{ type: string; payload: unknown }>): void => { + if (!origin.endsWith(new URL(src).host)) return; + + onMessage(data); + }; + + window.addEventListener('message', handleMessage); + + return () => window.removeEventListener('message', handleMessage); + }, [src, onMessage]); + + const frameSrc = useMemo(() => { + const url = new URL(src); + url.searchParams.set('embedOrigin', window.location.origin); + + return url.toString(); + }, [src]); + + const iframe = ( +