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 (
+
+
+
+
+
+
+ {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 && (
+
+ {t({ defaultMessage: 'Link a ScholAIstic course' })}
+
+ )}
+
+ {initialValues.pingResult && (
+ <>
+
+
+ setConfirmUnlinkPromptOpen(true)}
+ variant="outlined"
+ >
+ {t({ defaultMessage: 'Unlink these courses' })}
+
+
+ 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 = (
+
+ );
+
+ if (!framed) return iframe;
+
+ const srcOrigin = new URL(src).origin;
+
+ return (
+
+
+ {t(
+ {
+ defaultMessage:
+ "This section is an embedded experience from {src}. If you can't see some fields, buttons, or elements, try scrolling around this box.",
+ },
+ {
+ src: (
+
+ {srcOrigin}
+
+ ),
+ },
+ )}
+
+
+ {iframe}
+
+ );
+};
+
+export const useScholaisticFrameEvents = ({
+ src,
+ on: listeners,
+}: {
+ src: string;
+ on: Record void>;
+}): void => {
+ useEffect(() => {
+ const handleMessage = ({
+ origin,
+ data,
+ }: MessageEvent<{ type: string; payload: unknown }>): void => {
+ if (origin !== new URL(src).origin) return;
+
+ listeners[data.type]?.(data.payload);
+ };
+
+ window.addEventListener('message', handleMessage);
+
+ return () => window.removeEventListener('message', handleMessage);
+ });
+};
+
+export default ScholaisticFramePage;
diff --git a/client/app/bundles/course/scholaistic/handles.ts b/client/app/bundles/course/scholaistic/handles.ts
new file mode 100644
index 00000000000..a37d599e2a0
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/handles.ts
@@ -0,0 +1,53 @@
+import { defineMessage } from 'react-intl';
+
+import { DataHandle } from 'lib/hooks/router/dynamicNest';
+import { Descriptor } from 'lib/hooks/useTranslation';
+
+type HandleStorage = Partial<{
+ [K in 'assessments' | 'assessment' | 'assistant' | 'submission']:
+ | string
+ | Descriptor
+ | null
+ | undefined;
+}>;
+
+const asyncHandleStorage: { current?: Promise } = {};
+
+export const setAsyncHandle = (promise: Promise): void => {
+ asyncHandleStorage.current = promise;
+};
+
+export const assessmentsHandle: DataHandle = () => ({
+ getData: async (): Promise => {
+ const handle = await asyncHandleStorage.current;
+
+ return (
+ handle?.assessments ||
+ defineMessage({ defaultMessage: 'Role-Playing Assessments' })
+ );
+ },
+});
+
+export const assessmentHandle: DataHandle = () => ({
+ getData: async (): Promise => {
+ const handle = await asyncHandleStorage.current;
+
+ return handle?.assessment || '';
+ },
+});
+
+export const submissionHandle: DataHandle = () => ({
+ getData: async (): Promise => {
+ const handle = await asyncHandleStorage.current;
+
+ return handle?.submission || '';
+ },
+});
+
+export const assistantHandle: DataHandle = () => ({
+ getData: async (): Promise => {
+ const handle = await asyncHandleStorage.current;
+
+ return handle?.assistant || '';
+ },
+});
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentEdit/index.tsx b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentEdit/index.tsx
new file mode 100644
index 00000000000..df039405edd
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentEdit/index.tsx
@@ -0,0 +1,97 @@
+import { useCallback, useRef, useState } from 'react';
+import { Controller } from 'react-hook-form';
+import { defineMessage } from 'react-intl';
+import { useParams } from 'react-router-dom';
+import { ScholaisticAssessmentUpdateData } from 'types/course/scholaistic';
+import { getIdFromUnknown } from 'utilities';
+
+import assessmentFormTranslations from 'course/assessment/components/AssessmentForm/translations';
+import { withScholaisticAsyncContainer } from 'course/scholaistic/components/ScholaisticAsyncContainer';
+import ScholaisticFramePage from 'course/scholaistic/components/ScholaisticFramePage';
+import Page from 'lib/components/core/layouts/Page';
+import Section from 'lib/components/core/layouts/Section';
+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 { updateScholaisticAssessment } from './operations';
+
+const ScholaisticAssessmentEdit = (): JSX.Element => {
+ const { t } = useTranslation();
+
+ const data = useLoader();
+
+ const assessmentId = getIdFromUnknown(useParams().assessmentId)!;
+
+ const [submitting, setSubmitting] = useState(false);
+
+ const formRef = useRef>(null);
+
+ const handleSubmit = useCallback(
+ (newData: ScholaisticAssessmentUpdateData): void => {
+ setSubmitting(true);
+
+ updateScholaisticAssessment(assessmentId, newData)
+ .then(() => {
+ formRef.current?.resetTo?.(newData);
+ toast.success(t(formTranslations.changesSaved));
+ })
+ .catch(formRef.current?.receiveErrors)
+ .finally(() => setSubmitting(false));
+ },
+ [],
+ );
+
+ return (
+
+
+
+ {data.display.isGamified && (
+
+ )}
+
+ );
+};
+
+export const handle = defineMessage({ defaultMessage: 'Edit' });
+
+export default withScholaisticAsyncContainer(ScholaisticAssessmentEdit);
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentEdit/loader.ts b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentEdit/loader.ts
new file mode 100644
index 00000000000..a5d8ae7e399
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentEdit/loader.ts
@@ -0,0 +1,27 @@
+import { defer, LoaderFunction, useAsyncValue } from 'react-router-dom';
+import { ScholaisticAssessmentEditData } from 'types/course/scholaistic';
+import { getIdFromUnknown } from 'utilities';
+
+import CourseAPI from 'api/course';
+import { setAsyncHandle } from 'course/scholaistic/handles';
+
+export const loader: LoaderFunction = ({ params }) =>
+ defer({
+ promise: (async (): Promise => {
+ const promise = CourseAPI.scholaistic.fetchEditAssessment(
+ getIdFromUnknown(params.assessmentId)!,
+ );
+
+ setAsyncHandle(
+ promise.then(({ data }) => ({
+ assessments: data.display.assessmentsTitle,
+ assessment: data.display.assessmentTitle,
+ })),
+ );
+
+ return (await promise).data;
+ })(),
+ });
+
+export const useLoader = (): ScholaisticAssessmentEditData =>
+ useAsyncValue() as ScholaisticAssessmentEditData;
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentEdit/operations.ts b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentEdit/operations.ts
new file mode 100644
index 00000000000..df484e4f54a
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentEdit/operations.ts
@@ -0,0 +1,21 @@
+import { AxiosError } from 'axios';
+import { ScholaisticAssessmentUpdateData } from 'types/course/scholaistic';
+
+import CourseAPI from 'api/course';
+
+export const updateScholaisticAssessment = async (
+ assessmentId: number,
+ data: ScholaisticAssessmentUpdateData,
+): Promise => {
+ try {
+ const response = await CourseAPI.scholaistic.updateAssessment(
+ assessmentId,
+ { scholaistic_assessment: { base_exp: data.baseExp } },
+ );
+
+ return response.data;
+ } catch (error) {
+ if (error instanceof AxiosError) throw error.response?.data?.errors;
+ throw error;
+ }
+};
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentNew/index.tsx b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentNew/index.tsx
new file mode 100644
index 00000000000..03100f908f5
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentNew/index.tsx
@@ -0,0 +1,16 @@
+import { defineMessage } from 'react-intl';
+
+import { withScholaisticAsyncContainer } from 'course/scholaistic/components/ScholaisticAsyncContainer';
+import ScholaisticFramePage from 'course/scholaistic/components/ScholaisticFramePage';
+
+import { useLoader } from './loader';
+
+const ScholaisticAssessmentNew = (): JSX.Element => {
+ const data = useLoader();
+
+ return ;
+};
+
+export const handle = defineMessage({ defaultMessage: 'New' });
+
+export default withScholaisticAsyncContainer(ScholaisticAssessmentNew);
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentNew/loader.ts b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentNew/loader.ts
new file mode 100644
index 00000000000..b87acacf9f6
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentNew/loader.ts
@@ -0,0 +1,23 @@
+import { defer, LoaderFunction, useAsyncValue } from 'react-router-dom';
+import { ScholaisticAssessmentNewData } from 'types/course/scholaistic';
+
+import CourseAPI from 'api/course';
+import { setAsyncHandle } from 'course/scholaistic/handles';
+
+export const loader: LoaderFunction = () =>
+ defer({
+ promise: (async (): Promise => {
+ const promise = CourseAPI.scholaistic.fetchNewAssessment();
+
+ setAsyncHandle(
+ promise.then(({ data }) => ({
+ assessments: data.display.assessmentsTitle,
+ })),
+ );
+
+ return (await promise).data;
+ })(),
+ });
+
+export const useLoader = (): ScholaisticAssessmentNewData =>
+ useAsyncValue() as ScholaisticAssessmentNewData;
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionEdit/index.tsx b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionEdit/index.tsx
new file mode 100644
index 00000000000..8fa315c070f
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionEdit/index.tsx
@@ -0,0 +1,56 @@
+import { useParams } from 'react-router-dom';
+import { getIdFromUnknown } from 'utilities';
+import { object, string } from 'yup';
+
+import CourseAPI from 'api/course';
+import submissionTranslations from 'course/assessment/submission/translations';
+import { withScholaisticAsyncContainer } from 'course/scholaistic/components/ScholaisticAsyncContainer';
+import ScholaisticFramePage from 'course/scholaistic/components/ScholaisticFramePage';
+import toast from 'lib/hooks/toast';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import { useLoader } from './loader';
+
+const ScholaisticAssessmentSubmissionEdit = (): JSX.Element => {
+ const data = useLoader();
+
+ const { t } = useTranslation();
+
+ const assessmentId = getIdFromUnknown(useParams().assessmentId)!;
+
+ return (
+ {
+ if (type !== 'submitted') return;
+
+ (async (): Promise => {
+ try {
+ const { submissionId } = await object({
+ submissionId: string().required(),
+ }).validate(payload);
+
+ await CourseAPI.scholaistic.fetchSubmission(
+ assessmentId,
+ submissionId,
+ );
+
+ toast.success(t(submissionTranslations.updateSuccess));
+ } catch (error) {
+ if (!(error instanceof Error)) throw error;
+
+ toast.error(
+ t(submissionTranslations.updateFailure, {
+ errors: error.message,
+ }),
+ );
+ }
+ })();
+ }}
+ src={data.embedSrc}
+ />
+ );
+};
+
+export default withScholaisticAsyncContainer(
+ ScholaisticAssessmentSubmissionEdit,
+);
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionEdit/loader.ts b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionEdit/loader.ts
new file mode 100644
index 00000000000..810acdca465
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionEdit/loader.ts
@@ -0,0 +1,45 @@
+import {
+ defer,
+ LoaderFunction,
+ redirect,
+ useAsyncValue,
+} from 'react-router-dom';
+import { ScholaisticAssessmentSubmissionEditData } from 'types/course/scholaistic';
+import { getIdFromUnknown } from 'utilities';
+
+import CourseAPI from 'api/course';
+import { setAsyncHandle } from 'course/scholaistic/handles';
+
+export const loader: LoaderFunction = ({ params }) =>
+ defer({
+ promise: (async (): Promise => {
+ const promise = CourseAPI.scholaistic.fetchSubmission(
+ getIdFromUnknown(params.assessmentId)!,
+ params.submissionId!,
+ );
+
+ setAsyncHandle(
+ promise.then(({ data }) => ({
+ assessments: data.display.assessmentsTitle,
+ assessment: data.display.assessmentTitle,
+ submission: data.display.creatorName,
+ })),
+ );
+
+ return (await promise).data;
+ })(),
+ });
+
+export const useLoader = (): ScholaisticAssessmentSubmissionEditData =>
+ useAsyncValue() as ScholaisticAssessmentSubmissionEditData;
+
+export const submissionLoader: LoaderFunction = async ({ params }) => {
+ const assessmentId = getIdFromUnknown(params.assessmentId)!;
+
+ const { data } =
+ await CourseAPI.scholaistic.findOrCreateSubmission(assessmentId);
+
+ return redirect(
+ `/courses/${params.courseId!}/scholaistic/assessments/${assessmentId}/submissions/${data.id}`,
+ );
+};
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionsIndex/index.tsx b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionsIndex/index.tsx
new file mode 100644
index 00000000000..b3fbb80d70f
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionsIndex/index.tsx
@@ -0,0 +1,18 @@
+import { defineMessage } from 'react-intl';
+
+import { withScholaisticAsyncContainer } from 'course/scholaistic/components/ScholaisticAsyncContainer';
+import ScholaisticFramePage from 'course/scholaistic/components/ScholaisticFramePage';
+
+import { useLoader } from './loader';
+
+const ScholaisticAssessmentSubmissionsIndex = (): JSX.Element => {
+ const data = useLoader();
+
+ return ;
+};
+
+export const handle = defineMessage({ defaultMessage: 'Submissions' });
+
+export default withScholaisticAsyncContainer(
+ ScholaisticAssessmentSubmissionsIndex,
+);
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionsIndex/loader.ts b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionsIndex/loader.ts
new file mode 100644
index 00000000000..c8d4f45ad93
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentSubmissionsIndex/loader.ts
@@ -0,0 +1,27 @@
+import { defer, LoaderFunction, useAsyncValue } from 'react-router-dom';
+import { ScholaisticAssessmentSubmissionsIndexData } from 'types/course/scholaistic';
+import { getIdFromUnknown } from 'utilities';
+
+import CourseAPI from 'api/course';
+import { setAsyncHandle } from 'course/scholaistic/handles';
+
+export const loader: LoaderFunction = async ({ params }) =>
+ defer({
+ promise: (async (): Promise => {
+ const promise = CourseAPI.scholaistic.fetchSubmissions(
+ getIdFromUnknown(params.assessmentId)!,
+ );
+
+ setAsyncHandle(
+ promise.then(({ data }) => ({
+ assessments: data.display.assessmentsTitle,
+ assessment: data.display.assessmentTitle,
+ })),
+ );
+
+ return (await promise).data;
+ })(),
+ });
+
+export const useLoader = (): ScholaisticAssessmentSubmissionsIndexData =>
+ useAsyncValue() as ScholaisticAssessmentSubmissionsIndexData;
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentView/index.tsx b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentView/index.tsx
new file mode 100644
index 00000000000..eee972dd1d5
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentView/index.tsx
@@ -0,0 +1,12 @@
+import { withScholaisticAsyncContainer } from 'course/scholaistic/components/ScholaisticAsyncContainer';
+import ScholaisticFramePage from 'course/scholaistic/components/ScholaisticFramePage';
+
+import { useLoader } from './loader';
+
+const ScholaisticAssessmentView = (): JSX.Element => {
+ const data = useLoader();
+
+ return ;
+};
+
+export default withScholaisticAsyncContainer(ScholaisticAssessmentView);
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentView/loader.ts b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentView/loader.ts
new file mode 100644
index 00000000000..229ae7c0394
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentView/loader.ts
@@ -0,0 +1,27 @@
+import { defer, LoaderFunction, useAsyncValue } from 'react-router-dom';
+import { ScholaisticAssessmentViewData } from 'types/course/scholaistic';
+import { getIdFromUnknown } from 'utilities';
+
+import CourseAPI from 'api/course';
+import { setAsyncHandle } from 'course/scholaistic/handles';
+
+export const loader: LoaderFunction = ({ params }) =>
+ defer({
+ promise: (async (): Promise => {
+ const promise = CourseAPI.scholaistic.fetchAssessment(
+ getIdFromUnknown(params.assessmentId)!,
+ );
+
+ setAsyncHandle(
+ promise.then(({ data }) => ({
+ assessments: data.display.assessmentsTitle,
+ assessment: data.display.assessmentTitle,
+ })),
+ );
+
+ return (await promise).data;
+ })(),
+ });
+
+export const useLoader = (): ScholaisticAssessmentViewData =>
+ useAsyncValue() as ScholaisticAssessmentViewData;
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentsIndex/ActionButtons.tsx b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentsIndex/ActionButtons.tsx
new file mode 100644
index 00000000000..12fd73d4fc6
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentsIndex/ActionButtons.tsx
@@ -0,0 +1,72 @@
+import { Create, Inventory } from '@mui/icons-material';
+import { Button, IconButton, Tooltip } from '@mui/material';
+import { ScholaisticAssessmentData } from 'types/course/scholaistic';
+
+import { ACTION_LABELS } from 'course/assessment/pages/AssessmentsIndex/ActionButtons';
+import UnavailableMessage from 'course/assessment/pages/AssessmentsIndex/UnavailableMessage';
+import assessmentTranslations from 'course/assessment/translations';
+import Link from 'lib/components/core/Link';
+import useTranslation from 'lib/hooks/useTranslation';
+
+const ActionButtons = ({
+ assessmentId,
+ status,
+ isStartTimeBegin,
+ showEditButton,
+ showSubmissionsButton,
+}: {
+ assessmentId: ScholaisticAssessmentData['id'];
+ status: ScholaisticAssessmentData['status'];
+ isStartTimeBegin?: boolean;
+ showEditButton?: boolean;
+ showSubmissionsButton?: boolean;
+}): JSX.Element => {
+ const { t } = useTranslation();
+
+ return (
+
+ {status !== 'unavailable' ? (
+
+
+ {t(ACTION_LABELS[status])}
+
+
+ ) : (
+
+ )}
+
+ {showEditButton && (
+
+
+
+
+
+
+
+ )}
+
+ {showSubmissionsButton && (
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default ActionButtons;
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentsIndex/index.tsx b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentsIndex/index.tsx
new file mode 100644
index 00000000000..fba548f726c
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentsIndex/index.tsx
@@ -0,0 +1,155 @@
+import { useMemo } from 'react';
+import { Block } from '@mui/icons-material';
+import { Button, Chip, Tooltip, Typography } from '@mui/material';
+import { ScholaisticAssessmentData } from 'types/course/scholaistic';
+import { cn } from 'utilities';
+
+import assessmentTranslations from 'course/assessment/translations';
+import { withScholaisticAsyncContainer } from 'course/scholaistic/components/ScholaisticAsyncContainer';
+import Page from 'lib/components/core/layouts/Page';
+import Link from 'lib/components/core/Link';
+import Table, { ColumnTemplate } from 'lib/components/table';
+import useTranslation from 'lib/hooks/useTranslation';
+import { formatMiniDateTime } from 'lib/moment';
+
+import ActionButtons from './ActionButtons';
+import { useLoader } from './loader';
+
+const ScholaisticAssessmentsIndex = (): JSX.Element => {
+ const data = useLoader();
+
+ const { t } = useTranslation();
+
+ const hasEndTimes = useMemo(
+ () => data.assessments.some(({ endAt }) => endAt !== undefined),
+ [data.assessments],
+ );
+
+ const columns: ColumnTemplate[] = [
+ {
+ of: 'title',
+ title: t(assessmentTranslations.title),
+ cell: (assessment) => (
+
+
+ {assessment.title}
+
+
+ {!assessment.published && (
+
+ }
+ label={t(assessmentTranslations.draft)}
+ size="small"
+ variant="outlined"
+ />
+
+ )}
+
+ ),
+ },
+ {
+ of: 'baseExp',
+ title: t(assessmentTranslations.exp),
+ cell: (assessment) => assessment.baseExp ?? '-',
+ unless: !data.display.isGamified,
+ className: 'max-md:!hidden text-right',
+ },
+ {
+ of: 'startAt',
+ title: t(assessmentTranslations.startsAt),
+ cell: (assessment) => (
+
+ {formatMiniDateTime(assessment.startAt)}
+
+ ),
+ className: 'max-lg:!hidden whitespace-nowrap',
+ },
+ {
+ of: 'endAt',
+ title: t(assessmentTranslations.endsAt),
+ cell: (assessment) => (
+
+ {assessment.endAt ? formatMiniDateTime(assessment.endAt) : '-'}
+
+ ),
+ unless: !hasEndTimes,
+ className: 'whitespace-nowrap pointer-coarse:max-sm:!hidden',
+ },
+ {
+ id: 'actions',
+ title: t(assessmentTranslations.actions),
+ className: 'relative',
+ cell: (assessment) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ {t(assessmentTranslations.newAssessment)}
+
+
+ )
+ }
+ title={
+ data.display.assessmentsTitle ??
+ t({ defaultMessage: 'Role-Playing Assessments' })
+ }
+ unpadded
+ >
+
+ cn('bg-white hover?:bg-neutral-100', {
+ 'bg-neutral-100 hover?:bg-neutral-200/50':
+ assessment.status === 'unavailable',
+ 'bg-lime-50 hover?:bg-lime-100': assessment.status === 'submitted',
+ 'shadow-[2px_0_0_0_inset] shadow-amber-500':
+ assessment.status === 'attempting',
+ })
+ }
+ getRowId={(assessment) => assessment.id.toString()}
+ />
+
+ );
+};
+
+export default withScholaisticAsyncContainer(ScholaisticAssessmentsIndex);
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentsIndex/loader.ts b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentsIndex/loader.ts
new file mode 100644
index 00000000000..801308c279e
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssessmentsIndex/loader.ts
@@ -0,0 +1,23 @@
+import { defer, LoaderFunction, useAsyncValue } from 'react-router-dom';
+import { ScholaisticAssessmentsIndexData } from 'types/course/scholaistic';
+
+import CourseAPI from 'api/course';
+import { setAsyncHandle } from 'course/scholaistic/handles';
+
+export const loader: LoaderFunction = () =>
+ defer({
+ promise: (async (): Promise => {
+ const promise = CourseAPI.scholaistic.fetchAssessments();
+
+ setAsyncHandle(
+ promise.then(({ data }) => ({
+ assessments: data.display.assessmentsTitle,
+ })),
+ );
+
+ return (await promise).data;
+ })(),
+ });
+
+export const useLoader = (): ScholaisticAssessmentsIndexData =>
+ useAsyncValue() as ScholaisticAssessmentsIndexData;
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantEdit/index.tsx b/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantEdit/index.tsx
new file mode 100644
index 00000000000..85e595c6df3
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantEdit/index.tsx
@@ -0,0 +1,12 @@
+import { withScholaisticAsyncContainer } from 'course/scholaistic/components/ScholaisticAsyncContainer';
+import ScholaisticFramePage from 'course/scholaistic/components/ScholaisticFramePage';
+
+import { useLoader } from './loader';
+
+const ScholaisticAssistantEdit = (): JSX.Element => {
+ const data = useLoader();
+
+ return ;
+};
+
+export default withScholaisticAsyncContainer(ScholaisticAssistantEdit);
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantEdit/loader.ts b/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantEdit/loader.ts
new file mode 100644
index 00000000000..7f5c42be72d
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantEdit/loader.ts
@@ -0,0 +1,23 @@
+import { defer, LoaderFunction, useAsyncValue } from 'react-router-dom';
+import { ScholaisticAssistantEditData } from 'types/course/scholaistic';
+
+import CourseAPI from 'api/course';
+import { setAsyncHandle } from 'course/scholaistic/handles';
+
+export const loader: LoaderFunction = async ({ params }) =>
+ defer({
+ promise: (async (): Promise => {
+ const promise = CourseAPI.scholaistic.fetchAssistant(params.assistantId!);
+
+ setAsyncHandle(
+ promise.then(({ data }) => ({
+ assistant: data.display.assistantTitle,
+ })),
+ );
+
+ return (await promise).data;
+ })(),
+ });
+
+export const useLoader = (): ScholaisticAssistantEditData =>
+ useAsyncValue() as ScholaisticAssistantEditData;
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantsIndex/index.tsx b/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantsIndex/index.tsx
new file mode 100644
index 00000000000..0012ab43b2e
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantsIndex/index.tsx
@@ -0,0 +1,16 @@
+import { defineMessage } from 'react-intl';
+
+import { withScholaisticAsyncContainer } from 'course/scholaistic/components/ScholaisticAsyncContainer';
+import ScholaisticFramePage from 'course/scholaistic/components/ScholaisticFramePage';
+
+import { useLoader } from './loader';
+
+const ScholaisticAssistantsIndex = (): JSX.Element => {
+ const data = useLoader();
+
+ return ;
+};
+
+export const handle = defineMessage({ defaultMessage: 'Assistants' });
+
+export default withScholaisticAsyncContainer(ScholaisticAssistantsIndex);
diff --git a/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantsIndex/loader.ts b/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantsIndex/loader.ts
new file mode 100644
index 00000000000..448eb1eab79
--- /dev/null
+++ b/client/app/bundles/course/scholaistic/pages/ScholaisticAssistantsIndex/loader.ts
@@ -0,0 +1,16 @@
+import { defer, LoaderFunction, useAsyncValue } from 'react-router-dom';
+import { ScholaisticAssistantsIndexData } from 'types/course/scholaistic';
+
+import CourseAPI from 'api/course';
+
+export const loader: LoaderFunction = () =>
+ defer({
+ promise: (async (): Promise => {
+ const { data } = await CourseAPI.scholaistic.fetchAssistants();
+
+ return data;
+ })(),
+ });
+
+export const useLoader = (): ScholaisticAssistantsIndexData =>
+ useAsyncValue() as ScholaisticAssistantsIndexData;
diff --git a/client/app/lib/components/extensions/conditions/ConditionRow.tsx b/client/app/lib/components/extensions/conditions/ConditionRow.tsx
index d96fd1f6e0e..24ba8aeeee6 100644
--- a/client/app/lib/components/extensions/conditions/ConditionRow.tsx
+++ b/client/app/lib/components/extensions/conditions/ConditionRow.tsx
@@ -32,7 +32,7 @@ const ConditionRow = (
): JSX.Element => {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
- const { component } = specify(props.condition.type);
+ const { component, defaultDisplayName } = specify(props.condition.type);
const updateCondition = (
data: ConditionData,
@@ -52,7 +52,9 @@ const ConditionRow = (
return (
- {props.condition.type}
+
+ {props.condition.displayName || t(defaultDisplayName)}
+
diff --git a/client/app/lib/components/extensions/conditions/ConditionsManager.tsx b/client/app/lib/components/extensions/conditions/ConditionsManager.tsx
index b7eea88c9ba..5727760033d 100644
--- a/client/app/lib/components/extensions/conditions/ConditionsManager.tsx
+++ b/client/app/lib/components/extensions/conditions/ConditionsManager.tsx
@@ -191,7 +191,7 @@ const ConditionsManager = (props: ConditionsManagerProps): JSX.Element => {
setAdding(false);
}}
>
- {ability.type}
+ {ability.displayName || t(specify(ability.type).defaultDisplayName)}
))}
diff --git a/client/app/lib/components/extensions/conditions/conditions/ScholaisticAssessmentCondition.tsx b/client/app/lib/components/extensions/conditions/conditions/ScholaisticAssessmentCondition.tsx
new file mode 100644
index 00000000000..d82320b119f
--- /dev/null
+++ b/client/app/lib/components/extensions/conditions/conditions/ScholaisticAssessmentCondition.tsx
@@ -0,0 +1,138 @@
+import { Controller, useForm } from 'react-hook-form';
+import { Launch } from '@mui/icons-material';
+import { Autocomplete, Box, TextField, Typography } from '@mui/material';
+import {
+ AvailableScholaisticAssessments,
+ ScholaisticAssessmentConditionData,
+} from 'types/course/conditions';
+
+import CourseAPI from 'api/course';
+import Prompt from 'lib/components/core/dialogs/Prompt';
+import Link from 'lib/components/core/Link';
+import LoadingIndicator from 'lib/components/core/LoadingIndicator';
+import { formatErrorMessage } from 'lib/components/form/fields/utils/mapError';
+import Preload from 'lib/components/wrappers/Preload';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import { AnyConditionProps } from '../AnyCondition';
+import translations from '../translations';
+
+const ScholaisticAssessmentConditionForm = ({
+ condition,
+ conditionAbility,
+ onUpdate,
+ onClose,
+ open,
+ assessments: { ids, assessments },
+}: AnyConditionProps & {
+ assessments: AvailableScholaisticAssessments;
+}): JSX.Element => {
+ const { t } = useTranslation();
+
+ const { control, handleSubmit, setError, formState } = useForm({
+ defaultValues: condition ?? { assessmentId: ids[0] },
+ });
+
+ const updateAssessment = (data: ScholaisticAssessmentConditionData): void => {
+ onUpdate(data, (errors) =>
+ setError('assessmentId', { message: errors.errors.assessment }),
+ );
+ };
+
+ const isNewCondition = !condition;
+
+ return (
+
+ (
+ assessments[id].title}
+ onChange={(_, value): void => field.onChange(value)}
+ options={ids}
+ renderInput={(inputProps): JSX.Element => (
+
+ )}
+ renderOption={(optionProps, id): JSX.Element => (
+
+ {assessments[id].title}
+
+
+
+ {t(translations.details)}
+
+
+
+
+
+ )}
+ value={field.value}
+ />
+ )}
+ />
+
+ );
+};
+
+const ScholaisticAssessmentCondition = (
+ props: AnyConditionProps,
+): JSX.Element => {
+ const { condition, conditionAbility, onClose } = props;
+
+ const url = condition?.url ?? conditionAbility?.url;
+ if (!url)
+ throw new Error(
+ `ScholaisticAssessmentCondition received ${url} condition endpoint`,
+ );
+
+ return (
+ }
+ while={async () => {
+ const response =
+ await CourseAPI.conditions.fetchScholaisticAssessments(url);
+ return response.data;
+ }}
+ >
+ {(data): JSX.Element => (
+
+ )}
+
+ );
+};
+
+export default ScholaisticAssessmentCondition;
diff --git a/client/app/lib/components/extensions/conditions/specifiers.ts b/client/app/lib/components/extensions/conditions/specifiers.ts
index 7f6429a3998..da2f8900f5f 100644
--- a/client/app/lib/components/extensions/conditions/specifiers.ts
+++ b/client/app/lib/components/extensions/conditions/specifiers.ts
@@ -1,17 +1,23 @@
+import { defineMessage } from 'react-intl';
import {
AchievementConditionData,
AssessmentConditionData,
ConditionData,
ConditionPostData,
LevelConditionData,
+ ScholaisticAssessmentConditionData,
SurveyConditionData,
} from 'types/course/conditions';
+import { Descriptor } from 'lib/hooks/useTranslation';
+
import AchievementCondition from './conditions/AchievementCondition';
import AssessmentCondition from './conditions/AssessmentCondition';
import LevelCondition from './conditions/LevelCondition';
+import ScholaisticAssessmentCondition from './conditions/ScholaisticAssessmentCondition';
import SurveyCondition from './conditions/SurveyCondition';
import { AnyCondition } from './AnyCondition';
+import translations from './translations';
/**
* A construct that defines the necessary attributes for an unlock condition type.
@@ -20,6 +26,7 @@ interface Specifier {
component: AnyCondition;
extractUniqueData: (condition: AnyConditionData) => number | void;
adaptDataForPost: (data: Partial) => ConditionPostData;
+ defaultDisplayName: Descriptor;
}
type Specifiers = Record>;
@@ -30,6 +37,7 @@ const achievementSpecifier: Specifier = {
adaptDataForPost: (data) => ({
condition_achievement: { achievement_id: data.achievementId },
}),
+ defaultDisplayName: translations.achievement,
};
const assessmentSpecifier: Specifier = {
@@ -41,6 +49,7 @@ const assessmentSpecifier: Specifier = {
minimum_grade_percentage: data.minimumGradePercentage,
},
}),
+ defaultDisplayName: translations.assessment,
};
const levelSpecifier: Specifier = {
@@ -49,6 +58,7 @@ const levelSpecifier: Specifier = {
adaptDataForPost: (data) => ({
condition_level: { minimum_level: data.minimumLevel },
}),
+ defaultDisplayName: translations.level,
};
const surveySpecifier: Specifier = {
@@ -57,14 +67,29 @@ const surveySpecifier: Specifier = {
adaptDataForPost: (data) => ({
condition_survey: { survey_id: data.surveyId },
}),
+ defaultDisplayName: translations.survey,
};
+const scholaisticAssessmentSpecifier: Specifier =
+ {
+ component: ScholaisticAssessmentCondition,
+ extractUniqueData: (condition) => condition.assessmentId,
+ adaptDataForPost: (data) => ({
+ condition_scholaistic_assessment: {
+ scholaistic_assessment_id: data.assessmentId,
+ },
+ }),
+ defaultDisplayName: defineMessage({
+ defaultMessage: 'Role-Playing Assessment',
+ }),
+ };
+
const SPECIFIERS: Specifiers = {
- Achievement: achievementSpecifier,
- Assessment: assessmentSpecifier,
- Level: levelSpecifier,
- Survey: surveySpecifier,
- Video: achievementSpecifier,
+ achievement: achievementSpecifier,
+ assessment: assessmentSpecifier,
+ level: levelSpecifier,
+ survey: surveySpecifier,
+ scholaistic_assessment: scholaisticAssessmentSpecifier,
};
/**
diff --git a/client/app/lib/constants/icons.ts b/client/app/lib/constants/icons.ts
index 7df385043cc..e73e495c3f3 100644
--- a/client/app/lib/constants/icons.ts
+++ b/client/app/lib/constants/icons.ts
@@ -41,6 +41,8 @@ import {
SendOutlined,
Settings,
SettingsOutlined,
+ SmartToy,
+ SmartToyOutlined,
Speed,
SpeedOutlined,
Stairs,
@@ -88,6 +90,7 @@ export const COURSE_COMPONENT_ICONS = {
map: { outlined: MapOutlined, filled: MapIcon },
learn: { outlined: AssistantOutlined, filled: Assistant },
mission_control: { outlined: SpeedOutlined, filled: Speed },
+ chatbot: { outlined: SmartToyOutlined, filled: SmartToy },
} satisfies Record;
export type CourseComponentIconName = keyof typeof COURSE_COMPONENT_ICONS;
diff --git a/client/app/routers/course/admin.tsx b/client/app/routers/course/admin.tsx
index 8ee63b70bd9..675c8b30cca 100644
--- a/client/app/routers/course/admin.tsx
+++ b/client/app/routers/course/admin.tsx
@@ -162,6 +162,15 @@ const adminRouter: Translated = (_) => ({
).default,
}),
},
+ {
+ path: 'scholaistic',
+ lazy: async (): Promise => ({
+ loader: (await import('course/admin/pages/ScholaisticSettings/loader'))
+ .loader,
+ Component: (await import('course/admin/pages/ScholaisticSettings'))
+ .default,
+ }),
+ },
{
path: 'stories',
lazy: async (): Promise => ({
diff --git a/client/app/routers/course/index.tsx b/client/app/routers/course/index.tsx
index bd1db90f015..30dc19c7936 100644
--- a/client/app/routers/course/index.tsx
+++ b/client/app/routers/course/index.tsx
@@ -10,6 +10,7 @@ import groupsRouter from './groups';
import lessonPlanRouter from './lessonPlan';
import materialsRouter from './materials';
import plagiarismRouter from './plagiarism';
+import scholaisticRouter from './scholaistic';
import statisticsRouter from './statistics';
import surveysRouter from './surveys';
import usersRouter from './users';
@@ -47,6 +48,7 @@ const courseRouter: Translated = (t) => ({
surveysRouter(t),
usersRouter(t),
videosRouter(t),
+ scholaisticRouter(t),
{
index: true,
lazy: async (): Promise => {
diff --git a/client/app/routers/course/scholaistic.tsx b/client/app/routers/course/scholaistic.tsx
new file mode 100644
index 00000000000..eaecbf72335
--- /dev/null
+++ b/client/app/routers/course/scholaistic.tsx
@@ -0,0 +1,191 @@
+import { RouteObject } from 'react-router-dom';
+
+import { Translated } from 'lib/hooks/useTranslation';
+
+const scholaisticRouter: Translated = (t) => ({
+ path: 'scholaistic',
+ lazy: async (): Promise => ({
+ ErrorBoundary: (
+ await import('course/scholaistic/components/ScholaisticErrorPage')
+ ).default,
+ }),
+ children: [
+ {
+ path: 'assessments',
+ lazy: async (): Promise => ({
+ handle: (await import('course/scholaistic/handles')).assessmentsHandle,
+ }),
+ children: [
+ {
+ index: true,
+ lazy: async (): Promise => ({
+ Component: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentsIndex'
+ )
+ ).default,
+ loader: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentsIndex/loader'
+ )
+ ).loader,
+ }),
+ },
+ {
+ path: ':assessmentId',
+ lazy: async (): Promise => ({
+ handle: (await import('course/scholaistic/handles'))
+ .assessmentHandle,
+ }),
+ children: [
+ {
+ index: true,
+ lazy: async (): Promise => ({
+ Component: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentView'
+ )
+ ).default,
+ loader: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentView/loader'
+ )
+ ).loader,
+ }),
+ },
+ {
+ path: 'submission',
+ lazy: async (): Promise => ({
+ loader: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentSubmissionEdit/loader'
+ )
+ ).submissionLoader,
+ }),
+ },
+ {
+ path: 'submissions',
+ children: [
+ {
+ index: true,
+ lazy: async (): Promise => ({
+ Component: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentSubmissionsIndex'
+ )
+ ).default,
+ loader: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentSubmissionsIndex/loader'
+ )
+ ).loader,
+ handle: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentSubmissionsIndex'
+ )
+ ).handle,
+ }),
+ },
+ {
+ path: ':submissionId',
+ lazy: async (): Promise => ({
+ Component: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentSubmissionEdit'
+ )
+ ).default,
+ loader: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentSubmissionEdit/loader'
+ )
+ ).loader,
+ handle: (await import('course/scholaistic/handles'))
+ .submissionHandle,
+ }),
+ },
+ ],
+ },
+ {
+ path: 'edit',
+ lazy: async (): Promise => ({
+ Component: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentEdit'
+ )
+ ).default,
+ loader: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentEdit/loader'
+ )
+ ).loader,
+ handle: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentEdit'
+ )
+ ).handle,
+ }),
+ },
+ ],
+ },
+ {
+ path: 'new',
+ lazy: async (): Promise => ({
+ Component: (
+ await import('course/scholaistic/pages/ScholaisticAssessmentNew')
+ ).default,
+ loader: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssessmentNew/loader'
+ )
+ ).loader,
+ handle: (
+ await import('course/scholaistic/pages/ScholaisticAssessmentNew')
+ ).handle,
+ }),
+ },
+ ],
+ },
+ {
+ path: 'assistants',
+ lazy: async (): Promise => ({
+ handle: (
+ await import('course/scholaistic/pages/ScholaisticAssistantsIndex')
+ ).handle,
+ }),
+ children: [
+ {
+ index: true,
+ lazy: async (): Promise => ({
+ Component: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssistantsIndex'
+ )
+ ).default,
+ loader: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssistantsIndex/loader'
+ )
+ ).loader,
+ }),
+ },
+ {
+ path: ':assistantId',
+ lazy: async (): Promise => ({
+ Component: (
+ await import('course/scholaistic/pages/ScholaisticAssistantEdit')
+ ).default,
+ loader: (
+ await import(
+ 'course/scholaistic/pages/ScholaisticAssistantEdit/loader'
+ )
+ ).loader,
+ handle: (await import('course/scholaistic/handles'))
+ .assistantHandle,
+ }),
+ },
+ ],
+ },
+ ],
+});
+
+export default scholaisticRouter;
diff --git a/client/app/types/course/admin/scholaistic.ts b/client/app/types/course/admin/scholaistic.ts
new file mode 100644
index 00000000000..9e42dd74ea3
--- /dev/null
+++ b/client/app/types/course/admin/scholaistic.ts
@@ -0,0 +1,12 @@
+export interface ScholaisticSettingsData {
+ assessmentsTitle: string;
+ pingResult:
+ | { status: 'ok'; title: string; url: string }
+ | { status: 'error' };
+}
+
+export interface ScholaisticSettingsPostData {
+ settings_scholaistic_component: {
+ assessments_title: ScholaisticSettingsData['assessmentsTitle'];
+ };
+}
diff --git a/client/app/types/course/conditions.ts b/client/app/types/course/conditions.ts
index b5b85d92044..b1b287df636 100644
--- a/client/app/types/course/conditions.ts
+++ b/client/app/types/course/conditions.ts
@@ -4,13 +4,20 @@ export interface ConditionListData {
}
export interface ConditionData extends ConditionListData {
- type: 'Achievement' | 'Assessment' | 'Level' | 'Survey' | 'Video';
+ type:
+ | 'achievement'
+ | 'assessment'
+ | 'level'
+ | 'survey'
+ | 'scholaistic_assessment';
url?: string;
+ displayName?: string | null;
}
export interface ConditionAbility {
type: ConditionData['type'];
url: string;
+ displayName?: string | null;
}
export type EnabledConditions = ConditionAbility[];
@@ -37,6 +44,10 @@ export interface SurveyConditionData extends ConditionData {
surveyId?: number;
}
+export interface ScholaisticAssessmentConditionData extends ConditionData {
+ assessmentId?: number;
+}
+
export interface AvailableAssessments {
ids: AssessmentConditionData['id'][];
assessments: Record<
@@ -59,6 +70,14 @@ export type AvailableAchievements = Record<
}
>;
+export interface AvailableScholaisticAssessments {
+ ids: ScholaisticAssessmentConditionData['id'][];
+ assessments: Record<
+ ScholaisticAssessmentConditionData['id'],
+ { title: string; url: string }
+ >;
+}
+
export interface ConditionPostData {
condition_achievement?: {
achievement_id: AchievementConditionData['achievementId'];
@@ -73,4 +92,7 @@ export interface ConditionPostData {
condition_survey?: {
survey_id: SurveyConditionData['surveyId'];
};
+ condition_scholaistic_assessment?: {
+ scholaistic_assessment_id: ScholaisticAssessmentConditionData['assessmentId'];
+ };
}
diff --git a/client/app/types/course/courses.ts b/client/app/types/course/courses.ts
index 60cf0d0a4b9..25503a9fc8b 100644
--- a/client/app/types/course/courses.ts
+++ b/client/app/types/course/courses.ts
@@ -81,6 +81,7 @@ export interface SidebarItemData {
path: string;
icon: CourseComponentIconName;
unread?: number;
+ exact?: boolean;
}
export interface CourseUserProgressData {
diff --git a/client/app/types/course/scholaistic.ts b/client/app/types/course/scholaistic.ts
new file mode 100644
index 00000000000..ca89a498fb7
--- /dev/null
+++ b/client/app/types/course/scholaistic.ts
@@ -0,0 +1,88 @@
+export interface ScholaisticAssessmentData {
+ id: number;
+ title: string;
+ startAt: string;
+ endAt?: string;
+ published: boolean;
+ isStartTimeBegin: boolean;
+ isEndTimePassed?: boolean;
+ status: 'attempting' | 'submitted' | 'open' | 'unavailable';
+ baseExp?: number;
+}
+
+export interface ScholaisticAssessmentsIndexData {
+ assessments: ScholaisticAssessmentData[];
+ display: {
+ assessmentsTitle?: string;
+ isStudent: boolean;
+ isGamified: boolean;
+ canEditAssessments: boolean;
+ canCreateAssessments: boolean;
+ canViewSubmissions: boolean;
+ };
+}
+
+export interface ScholaisticAssessmentNewData {
+ embedSrc: string;
+ display: {
+ assessmentsTitle?: string;
+ };
+}
+
+export interface ScholaisticAssessmentEditData {
+ embedSrc: string;
+ assessment: {
+ baseExp: number;
+ };
+ display: {
+ assessmentTitle: string;
+ isGamified: boolean;
+ assessmentsTitle?: string;
+ };
+}
+
+export interface ScholaisticAssessmentUpdateData {
+ baseExp: number;
+}
+
+export interface ScholaisticAssessmentUpdatePostData {
+ scholaistic_assessment: {
+ base_exp: ScholaisticAssessmentUpdateData['baseExp'];
+ };
+}
+
+export interface ScholaisticAssessmentViewData {
+ embedSrc: string;
+ display: {
+ assessmentTitle: string;
+ assessmentsTitle?: string;
+ };
+}
+
+export interface ScholaisticAssessmentSubmissionsIndexData {
+ embedSrc: string;
+ display: {
+ assessmentTitle: string;
+ assessmentsTitle?: string;
+ };
+}
+
+export interface ScholaisticAssessmentSubmissionEditData {
+ embedSrc: string;
+ display: {
+ assessmentTitle: string;
+ creatorName: string;
+ assessmentsTitle?: string;
+ };
+}
+
+export interface ScholaisticAssistantEditData {
+ embedSrc: string;
+ display: {
+ assistantTitle: string;
+ };
+}
+
+export interface ScholaisticAssistantsIndexData {
+ embedSrc: string;
+}
diff --git a/client/app/utilities/index.ts b/client/app/utilities/index.ts
index e076f4b8826..ce7f0688913 100644
--- a/client/app/utilities/index.ts
+++ b/client/app/utilities/index.ts
@@ -1,3 +1,6 @@
+import { type ClassValue, clsx } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
export const getIdFromUnknown = (id?: string | null): number | undefined =>
parseInt(id ?? '', 10) || undefined;
@@ -27,3 +30,5 @@ export const formatReadableBytes = (bytes: number, decimals = 2): string => {
Math.ceil((bytes * 10 ** decimals) / KB ** unitIndex) / 10 ** decimals;
return `${parseFloat(quantizedBytes.toFixed(decimals))} ${SIZES[unitIndex]}`;
};
+
+export const cn = (...inputs: ClassValue[]): string => twMerge(clsx(inputs));
diff --git a/client/package.json b/client/package.json
index bfd11697e0b..4df3ad6a8bd 100644
--- a/client/package.json
+++ b/client/package.json
@@ -51,6 +51,7 @@
"chart.js": "^3.8.2",
"chartjs-adapter-moment": "^1.0.1",
"chartjs-plugin-zoom": "^2.0.1",
+ "clsx": "^2.1.1",
"coursemology-ckeditor": "github:Coursemology/CKEditor5-build-coursemology#v1.0.0",
"fabric": "^5.3.0",
"fast-deep-equal": "^3.1.3",
@@ -102,6 +103,7 @@
"redux-thunk": "^2.4.2",
"resize-observer-polyfill": "^1.5.1",
"rollbar": "^2.26.3",
+ "tailwind-merge": "^3.3.1",
"yup": "^0.32.11"
},
"devDependencies": {
diff --git a/client/yarn.lock b/client/yarn.lock
index f7d2985a675..0270f02a1e3 100644
--- a/client/yarn.lock
+++ b/client/yarn.lock
@@ -5213,6 +5213,11 @@ clsx@^2.1.0:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.0.tgz#e851283bcb5c80ee7608db18487433f7b23f77cb"
integrity sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==
+clsx@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
+ integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
+
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -11905,6 +11910,11 @@ synckit@^0.8.6:
"@pkgr/utils" "^2.4.2"
tslib "^2.6.2"
+tailwind-merge@^3.3.1:
+ version "3.3.1"
+ resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.3.1.tgz#a7e7db7c714f6020319e626ecfb7e7dac8393a4b"
+ integrity sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==
+
tailwindcss@^3.4.1:
version "3.4.1"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.1.tgz#f512ca5d1dd4c9503c7d3d28a968f1ad8f5c839d"
diff --git a/config/locales/en/activerecord/course/condition/scholaistic_assessment.yml b/config/locales/en/activerecord/course/condition/scholaistic_assessment.yml
new file mode 100644
index 00000000000..5fcfcfb4e04
--- /dev/null
+++ b/config/locales/en/activerecord/course/condition/scholaistic_assessment.yml
@@ -0,0 +1,12 @@
+en:
+ activerecord:
+ attributes:
+ course/condition/scholaistic_assessment/title:
+ complete: 'Complete %{title}'
+ errors:
+ models:
+ course/condition/scholaistic_assessment:
+ attributes:
+ scholaistic_assessment:
+ unique_dependency: 'cannot have duplicate conditions'
+ references_self: 'cannot have itself as condition'
diff --git a/config/locales/en/activerecord/course/scholaistic_assessment.yml b/config/locales/en/activerecord/course/scholaistic_assessment.yml
new file mode 100644
index 00000000000..b7094211428
--- /dev/null
+++ b/config/locales/en/activerecord/course/scholaistic_assessment.yml
@@ -0,0 +1,8 @@
+en:
+ activerecord:
+ errors:
+ models:
+ course/scholaistic_assessment:
+ attributes:
+ time_bonus_exp:
+ bonus_attributes_not_allowed: 'bonus attributes are not allowed'
diff --git a/config/locales/en/components.yml b/config/locales/en/components.yml
index 02d7e062024..92cad1b5609 100644
--- a/config/locales/en/components.yml
+++ b/config/locales/en/components.yml
@@ -43,6 +43,9 @@ en:
name: 'Multiple Reference Timelines'
monitoring:
name: 'Heartbeat Monitoring for Exams'
+ scholaistic:
+ name: 'Role-Playing Chatbots & Assessments'
+ manage_assistants: 'Assistants'
stories:
name: 'Stories'
koditsu_platform:
diff --git a/config/locales/en/course/scholaistic.yml b/config/locales/en/course/scholaistic.yml
new file mode 100644
index 00000000000..877fae00575
--- /dev/null
+++ b/config/locales/en/course/scholaistic.yml
@@ -0,0 +1,4 @@
+en:
+ course:
+ scholaistic:
+ assessments: "Role-Playing Assessments"
diff --git a/config/locales/ko/activerecord/course/condition/scholaistic_assessment.yml b/config/locales/ko/activerecord/course/condition/scholaistic_assessment.yml
new file mode 100644
index 00000000000..b7426ebbfa3
--- /dev/null
+++ b/config/locales/ko/activerecord/course/condition/scholaistic_assessment.yml
@@ -0,0 +1,12 @@
+ko:
+ activerecord:
+ attributes:
+ course/condition/scholaistic_assessment/title:
+ complete: '%{title} 완료'
+ errors:
+ models:
+ course/condition/scholaistic_assessment:
+ attributes:
+ scholaistic_assessment:
+ unique_dependency: '중복 조건을 가질 수 없습니다'
+ references_self: '자신 자신을 조건으로 가질 수 없습니다'
diff --git a/config/locales/ko/activerecord/course/scholaistic_assessment.yml b/config/locales/ko/activerecord/course/scholaistic_assessment.yml
new file mode 100644
index 00000000000..d6a44c65a60
--- /dev/null
+++ b/config/locales/ko/activerecord/course/scholaistic_assessment.yml
@@ -0,0 +1,8 @@
+ko:
+ activerecord:
+ errors:
+ models:
+ course/scholaistic_assessment:
+ attributes:
+ time_bonus_exp:
+ bonus_attributes_not_allowed: '보너스 속성은 허용되지 않습니다'
diff --git a/config/locales/ko/components.yml b/config/locales/ko/components.yml
index 3a14e8dbcda..b7535f7826e 100644
--- a/config/locales/ko/components.yml
+++ b/config/locales/ko/components.yml
@@ -43,6 +43,9 @@ ko:
name: '다중 참조 타임라인'
monitoring:
name: '시험 모니터링'
+ scholaistic:
+ name: '롤플레잉 챗봇 및 평가'
+ manage_assistants: '학습 조수'
stories:
name: '이야기'
koditsu_platform:
diff --git a/config/locales/ko/course/scholaistic.yml b/config/locales/ko/course/scholaistic.yml
new file mode 100644
index 00000000000..8271e0ed60f
--- /dev/null
+++ b/config/locales/ko/course/scholaistic.yml
@@ -0,0 +1,4 @@
+ko:
+ course:
+ scholaistic:
+ assessments: "롤플레잉 평가"
diff --git a/config/locales/zh/activerecord/course/condition/scholaistic_assessment.yml b/config/locales/zh/activerecord/course/condition/scholaistic_assessment.yml
new file mode 100644
index 00000000000..743c6320ac7
--- /dev/null
+++ b/config/locales/zh/activerecord/course/condition/scholaistic_assessment.yml
@@ -0,0 +1,12 @@
+zh:
+ activerecord:
+ attributes:
+ course/condition/scholaistic_assessment/title:
+ complete: '已完成%{title}'
+ errors:
+ models:
+ course/condition/scholaistic_assessment:
+ attributes:
+ scholaistic_assessment:
+ unique_dependency: '不能包含重复的解锁条件'
+ references_self: '不能将它自己作为解锁条件'
diff --git a/config/locales/zh/activerecord/course/scholaistic_assessment.yml b/config/locales/zh/activerecord/course/scholaistic_assessment.yml
new file mode 100644
index 00000000000..bbb2bddc78b
--- /dev/null
+++ b/config/locales/zh/activerecord/course/scholaistic_assessment.yml
@@ -0,0 +1,8 @@
+zh:
+ activerecord:
+ errors:
+ models:
+ course/scholaistic_assessment:
+ attributes:
+ time_bonus_exp:
+ bonus_attributes_not_allowed: '不允许使用奖励属性'
diff --git a/config/locales/zh/components.yml b/config/locales/zh/components.yml
index bda76bc0e65..a080aa23fcd 100644
--- a/config/locales/zh/components.yml
+++ b/config/locales/zh/components.yml
@@ -43,6 +43,9 @@ zh:
name: '多重参考时间线'
monitoring:
name: '监听'
+ scholaistic:
+ name: '角色扮演聊天机器人和评估'
+ manage_assistants: '学习助手'
stories:
name: '故事'
koditsu_platform:
diff --git a/config/locales/zh/course/scholaistic.yml b/config/locales/zh/course/scholaistic.yml
new file mode 100644
index 00000000000..28af39c13d6
--- /dev/null
+++ b/config/locales/zh/course/scholaistic.yml
@@ -0,0 +1,4 @@
+zh:
+ course:
+ scholaistic:
+ assessments: "角色扮演评估"
diff --git a/config/routes.rb b/config/routes.rb
index 2de24813420..487c50aa2c3 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -63,6 +63,7 @@
resources :levels, except: [:new, :edit]
resources :assessments, except: [:new, :edit]
resources :surveys, except: [:new, :edit]
+ resources :scholaistic_assessments, except: [:new, :edit]
end
end
@@ -143,6 +144,15 @@
end
end
+ namespace :scholaistic do
+ resources :scholaistic_assessments, as: :assessments, path: 'assessments', except: [:create, :destroy] do
+ get :submission, on: :member, to: 'submissions#submission'
+ resources :submissions, only: [:index, :show]
+ end
+
+ resources :assistants, only: [:index, :show]
+ end
+
namespace :admin do
get '/' => 'admin#index'
patch '/' => 'admin#update'
@@ -195,6 +205,15 @@
get 'stories' => 'stories_settings#edit'
patch 'stories' => 'stories_settings#update'
+ scope 'scholaistic', as: :scholaistic do
+ get '/' => 'scholaistic_settings#edit'
+ patch '/' => 'scholaistic_settings#update'
+
+ get 'link_course' => 'scholaistic_settings#link_course'
+ post 'confirm_link_course' => 'scholaistic_settings#confirm_link_course'
+ post 'unlink_course' => 'scholaistic_settings#unlink_course'
+ end
+
get 'rag_wise' => 'rag_wise_settings#edit'
patch 'rag_wise' => 'rag_wise_settings#update'
get 'rag_wise/materials' => 'rag_wise_settings#materials'
diff --git a/db/migrate/20250725030938_create_scholaistic_assessments.rb b/db/migrate/20250725030938_create_scholaistic_assessments.rb
new file mode 100644
index 00000000000..fb073e63b87
--- /dev/null
+++ b/db/migrate/20250725030938_create_scholaistic_assessments.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+class CreateScholaisticAssessments < ActiveRecord::Migration[7.2]
+ def change
+ create_table :course_scholaistic_assessments do |t|
+ t.string :upstream_id, null: false
+
+ t.timestamps null: false
+ end
+
+ create_table :course_condition_scholaistic_assessments do |t|
+ t.references :scholaistic_assessment, null: false, foreign_key: { to_table: :course_scholaistic_assessments }
+ end
+
+ create_table :course_scholaistic_submissions do |t|
+ t.string :upstream_id, null: false
+ t.references :assessment, null: false, foreign_key: { to_table: :course_scholaistic_assessments }
+
+ t.references :creator, null: false, foreign_key: { to_table: :users },
+ index: { name: 'fk__course_scholaistic_submissions_creator_id' }
+ t.timestamps null: false
+
+ t.index [:assessment_id, :creator_id], unique: true
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index a7763797afb..76030249b88 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: 2025_07_22_082737) do
+ActiveRecord::Schema[7.2].define(version: 2025_07_25_030938) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
enable_extension "uuid-ossp"
@@ -592,6 +592,11 @@
t.integer "minimum_level", null: false
end
+ create_table "course_condition_scholaistic_assessments", force: :cascade do |t|
+ t.bigint "scholaistic_assessment_id", null: false
+ t.index ["scholaistic_assessment_id"], name: "idx_on_scholaistic_assessment_id_60ce66b4ce"
+ end
+
create_table "course_condition_surveys", id: :serial, force: :cascade do |t|
t.bigint "survey_id", null: false
t.index ["survey_id"], name: "fk__course_condition_surveys_survey_id"
@@ -1104,6 +1109,23 @@
t.index ["reference_timeline_id"], name: "index_course_reference_times_on_reference_timeline_id"
end
+ create_table "course_scholaistic_assessments", force: :cascade do |t|
+ t.string "upstream_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ end
+
+ create_table "course_scholaistic_submissions", force: :cascade do |t|
+ t.string "upstream_id", null: false
+ t.bigint "assessment_id", null: false
+ t.bigint "creator_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["assessment_id", "creator_id"], name: "idx_on_assessment_id_creator_id_ac62df4c1b", unique: true
+ t.index ["assessment_id"], name: "index_course_scholaistic_submissions_on_assessment_id"
+ t.index ["creator_id"], name: "fk__course_scholaistic_submissions_creator_id"
+ end
+
create_table "course_settings_emails", force: :cascade do |t|
t.bigint "course_id", null: false
t.integer "component", null: false
@@ -1742,6 +1764,7 @@
add_foreign_key "course_assessments", "users", column: "updater_id", name: "fk_course_assessments_updater_id"
add_foreign_key "course_condition_achievements", "course_achievements", column: "achievement_id", name: "fk_course_condition_achievements_achievement_id"
add_foreign_key "course_condition_assessments", "course_assessments", column: "assessment_id", name: "fk_course_condition_assessments_assessment_id"
+ add_foreign_key "course_condition_scholaistic_assessments", "course_scholaistic_assessments", column: "scholaistic_assessment_id"
add_foreign_key "course_condition_surveys", "course_surveys", column: "survey_id", name: "fk_course_condition_surveys_survey_id"
add_foreign_key "course_condition_videos", "course_videos", column: "video_id", name: "fk_course_condition_videos_video_id"
add_foreign_key "course_conditions", "courses", name: "fk_course_conditions_course_id"
@@ -1833,6 +1856,8 @@
add_foreign_key "course_reference_timelines", "courses"
add_foreign_key "course_reference_times", "course_lesson_plan_items", column: "lesson_plan_item_id"
add_foreign_key "course_reference_times", "course_reference_timelines", column: "reference_timeline_id"
+ add_foreign_key "course_scholaistic_submissions", "course_scholaistic_assessments", column: "assessment_id"
+ add_foreign_key "course_scholaistic_submissions", "users", column: "creator_id"
add_foreign_key "course_settings_emails", "course_assessment_categories"
add_foreign_key "course_settings_emails", "courses"
add_foreign_key "course_survey_answer_options", "course_survey_answers", column: "answer_id", name: "fk_course_survey_answer_options_answer_id"
diff --git a/lib/extensions/conditional/active_record/base.rb b/lib/extensions/conditional/active_record/base.rb
index 8498fc0ac82..b7fcc3f0e12 100644
--- a/lib/extensions/conditional/active_record/base.rb
+++ b/lib/extensions/conditional/active_record/base.rb
@@ -164,6 +164,10 @@ def cyclic?
end
module ConditionClassMethods
+ # Custom display name for the condition. If not overridden, the client will use its default.
+ def display_name(course)
+ end
+
# Class that the condition depends on.
def dependent_class
raise NotImplementedError, 'Subclasses must implement a dependent_class method.'
diff --git a/spec/models/course/condition_spec.rb b/spec/models/course/condition_spec.rb
index a83e4a1bf6b..b0b6455914e 100644
--- a/spec/models/course/condition_spec.rb
+++ b/spec/models/course/condition_spec.rb
@@ -51,11 +51,14 @@
to receive(:dependent_class).and_return(Course::Achievement.name)
allow(Course::Condition::Video).
to receive(:dependent_class).and_return(Course::Achievement.name)
+ allow(Course::Condition::ScholaisticAssessment).
+ to receive(:dependent_class).and_return(Course::Achievement.name)
actual_mapping = Course::Condition.send(:dependent_class_to_condition_class_mapping)
expected_mapping = { Course::Achievement.name => [Course::Condition::Achievement.name,
Course::Condition::Assessment.name,
Course::Condition::Survey.name,
- Course::Condition::Video.name] }
+ Course::Condition::Video.name,
+ Course::Condition::ScholaisticAssessment.name] }
expect(actual_mapping).to eq(expected_mapping)
end
end