From 305c82b40cbb554f63fc6f8a61f46c7ddb6c5386 Mon Sep 17 00:00:00 2001 From: Tianshu Luan Date: Wed, 3 Jun 2026 11:46:32 -0400 Subject: [PATCH 1/4] Adding files that are required for jupyterlab submit button extension. Linked to pull request at https://github.com/MarkUsProject/markus-jupyter-extension/pull/3 --- .../api/jupyter_submissions_controller.rb | 334 ++++++++++++++++++ app/services/jupyter_identity_fetcher.rb | 97 +++++ app/services/jupyter_notebook_fetcher.rb | 116 ++++++ app/services/jupyter_submission_writer.rb | 100 ++++++ config/initializers/cors.rb | 18 +- .../cors_jupyter_submissions_example.rb | 13 + config/routes.rb | 2 + config/routes_addition.rb | 8 + 8 files changed, 682 insertions(+), 6 deletions(-) create mode 100644 app/controllers/api/jupyter_submissions_controller.rb create mode 100644 app/services/jupyter_identity_fetcher.rb create mode 100644 app/services/jupyter_notebook_fetcher.rb create mode 100644 app/services/jupyter_submission_writer.rb create mode 100644 config/initializers/cors_jupyter_submissions_example.rb create mode 100644 config/routes_addition.rb diff --git a/app/controllers/api/jupyter_submissions_controller.rb b/app/controllers/api/jupyter_submissions_controller.rb new file mode 100644 index 0000000000..cce830c194 --- /dev/null +++ b/app/controllers/api/jupyter_submissions_controller.rb @@ -0,0 +1,334 @@ +# frozen_string_literal: true + +require 'json' + +module Api + class JupyterSubmissionsController < ApplicationController + include SubmissionsHelper + + # Local prototype only. + # For production, replace these skips with proper MarkUs/JupyterHub authorization. + skip_before_action :verify_authenticity_token, only: [:create], raise: false + skip_verify_authorized only: :create + + def create + payload = request.request_parameters.presence || params.to_unsafe_h + + jupyter_info = payload['jupyter'] || {} + notebook_path = payload['notebook_path'] + + destination_path = sanitize_destination_path( + payload['destination_path'].presence || + payload['notebook_name'].presence || + File.basename(notebook_path.to_s) + ) + + assignment = find_assignment_from_payload!(payload) + + # Intended production flow: + # Jupyter token -> JupyterHub identity -> MarkUs user -> MarkUs student role. + # + # Local standalone JupyterLab testing: + # Set JUPYTER_DEV_USERNAME in docker-compose.yml. + student = current_role || find_student_role_from_jupyter_token!(jupyter_info, assignment) + + @jupyter_markus_role = student + @jupyter_markus_user = role_user!(student) + + grouping = find_or_create_grouping_for_student!(student, assignment) + + notebook = JupyterNotebookFetcher.new( + origin: jupyter_info['origin'], + base_url: jupyter_info['base_url'], + token: jupyter_info['token'], + notebook_path: notebook_path + ).fetch + + notebook_content = notebook_content_as_string(notebook[:content]) + + # SubmissionsHelper#upload_file expects API-style params: + # filename, mime_type, and file_content. + params[:filename] = destination_path + params[:mime_type] = 'application/x-ipynb+json' + params[:file_content] = notebook_content + + Rails.logger.info( + "[JupyterSubmissionsController] Submitting #{destination_path} " \ + "for user=#{@jupyter_markus_user.user_name}, " \ + "assignment_id=#{assignment.id}, grouping_id=#{grouping.id}" + ) + + upload_file(grouping, only_required_files: assignment.only_required_files) + + # upload_file may already render a MarkUs response. + # Avoid double-rendering if that happened. + return if performed? + + render json: { + status: 'success', + message: 'Notebook submitted to MarkUs.', + submitted_file: destination_path, + markus_target: { + assignment_id: assignment.id, + assignment: assignment.short_identifier, + repository_folder: assignment.repository_folder, + grouping_id: grouping.id, + group_id: grouping.group_id, + student_role_id: student.id, + markus_user_name: @jupyter_markus_user.user_name + }, + fetched_notebook: { + name: notebook[:name], + path: notebook[:path], + type: notebook[:type], + format: notebook[:format] + } + }, status: :ok + rescue JupyterIdentityFetcher::IdentityError => e + return if performed? + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :unauthorized + rescue JupyterNotebookFetcher::FetchError => e + return if performed? + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :bad_gateway + rescue ActiveRecord::RecordNotFound, ArgumentError => e + return if performed? + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :not_found + rescue StandardError => e + Rails.logger.error("[JupyterSubmissionsController] #{e.class}: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) if e.backtrace + + return if performed? + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :internal_server_error + end + + private + + # These two overrides are important for local prototype mode. + # MarkUs's upload helper may call current_user/current_role internally. + def current_user + return @jupyter_markus_user if @jupyter_markus_user.present? + + super if defined?(super) + end + + def current_role + return @jupyter_markus_role if @jupyter_markus_role.present? + + super if defined?(super) + end + + def find_assignment_from_payload!(payload) + if payload['assessment_id'].present? + return Assignment.find(payload['assessment_id']) + end + + if payload['assignment_id'].present? + return Assignment.find(payload['assignment_id']) + end + + assignment_key = payload['assignment'].to_s.strip + + if assignment_key.blank? + raise ActiveRecord::RecordNotFound, + 'Missing assignment, assignment_id, or assessment_id in notebook metadata.' + end + + course = find_course_from_payload(payload) + scope = course ? Assignment.where(course_id: course.id) : Assignment.all + + assignment = if integer_string?(assignment_key) + scope.find_by(id: assignment_key) + else + scope.find_by(short_identifier: assignment_key) + end + + if assignment.nil? + raise ActiveRecord::RecordNotFound, + "No MarkUs assignment found for #{assignment_key.inspect}." + end + + assignment + end + + def find_course_from_payload(payload) + course_id = payload['course_id'] + return Course.find(course_id) if course_id.present? + + course_key = payload['course'].to_s.strip + return nil if course_key.blank? + + return Course.find(course_key) if integer_string?(course_key) + + find_course_by_existing_column(course_key) + end + + def find_course_by_existing_column(course_key) + possible_columns = %w[name display_name short_identifier] + + possible_columns.each do |column| + next unless Course.column_names.include?(column) + + course = Course.find_by(column => course_key) + return course if course + end + + nil + end + + def find_student_role_from_jupyter_token!(jupyter_info, assignment) + username = resolve_jupyter_username!(jupyter_info) + + Rails.logger.info( + "[JupyterSubmissionsController] Jupyter token resolved to username=#{username}" + ) + + user = User.find_by(user_name: username) + + if user.nil? + raise ActiveRecord::RecordNotFound, + "No MarkUs user found with user_name=#{username.inspect}." + end + + role = find_role_for_user_and_course(user, assignment.course_id) + + if role.nil? + raise ActiveRecord::RecordNotFound, + "No student role found for Jupyter/MarkUs username #{username.inspect} " \ + "in course_id=#{assignment.course_id}." + end + + role + end + + def resolve_jupyter_username!(jupyter_info) + # Local development fallback only. + # This is useful when testing with standalone JupyterLab, where the token + # authenticates the server but does not identify a real JupyterHub user. + if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? + return ENV['JUPYTER_DEV_USERNAME'] + end + + JupyterIdentityFetcher.new( + origin: jupyter_info['origin'], + base_url: jupyter_info['base_url'], + token: jupyter_info['token'] + ).username + end + + def find_role_for_user_and_course(user, course_id) + if user.respond_to?(:roles) + role = user.roles.find_by(course_id: course_id) + return role if role + end + + if defined?(Student) + role = Student.find_by(user_id: user.id, course_id: course_id) + return role if role + end + + if defined?(Role) + role = Role.find_by(user_id: user.id, course_id: course_id) + return role if role + end + + nil + end + + def role_user!(role) + user = role_user(role) + + if user.nil? + raise ActiveRecord::RecordNotFound, + "Could not resolve a MarkUs User for role id=#{role&.id.inspect}." + end + + user + end + + def role_user(role) + return role.user if role.respond_to?(:user) && role.user.present? + + if role.respond_to?(:user_id) && role.user_id.present? + user = User.find_by(id: role.user_id) + return user if user + end + + nil + end + + def find_or_create_grouping_for_student!(student, assignment) + if student.respond_to?(:has_accepted_grouping_for?) && + student.has_accepted_grouping_for?(assignment.id) + return student.accepted_grouping_for(assignment.id) + end + + if assignment.group_max == 1 + student.create_group_for_working_alone_student(assignment.id) + grouping = student.accepted_grouping_for(assignment.id) + return grouping if grouping + end + + if student.respond_to?(:create_autogenerated_name_group) + grouping = student.create_autogenerated_name_group(assignment) + return grouping if grouping + end + + raise ActiveRecord::RecordNotFound, + 'Could not find or create a grouping for this student and assignment.' + end + + def notebook_content_as_string(content) + case content + when String + content + when Hash, Array + JSON.pretty_generate(content) + else + content.to_json + end + end + + def sanitize_destination_path(path) + filename = File.basename(path.to_s.strip) + + raise ArgumentError, 'Destination filename is missing.' if filename.blank? + + unless filename.end_with?('.ipynb') + raise ArgumentError, 'Only .ipynb notebook submissions are currently supported.' + end + + filename + end + + # SubmissionsHelper#upload_file expects this helper method to exist. + # MainApiController has it, but this controller inherits from ApplicationController + # to avoid the MarkUs API permission layer for the local prototype. + def has_missing_params?(required_params) + required_params.any? { |param| params[param].blank? } + end + + def integer_string?(value) + value.to_s.match?(/\A\d+\z/) + end + end +end \ No newline at end of file diff --git a/app/services/jupyter_identity_fetcher.rb b/app/services/jupyter_identity_fetcher.rb new file mode 100644 index 0000000000..90e3397626 --- /dev/null +++ b/app/services/jupyter_identity_fetcher.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'uri' + +class JupyterIdentityFetcher + class IdentityError < StandardError; end + + def initialize(origin:, base_url:, token:) + @origin = normalize_origin(origin) + @base_url = normalize_base_url(base_url) + @token = token.to_s + end + + def username + # Local development fallback only. + # This allows standalone JupyterLab testing where there is no JupyterHub identity endpoint. + if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? + Rails.logger.info("[JupyterIdentityFetcher] Using development username #{ENV['JUPYTER_DEV_USERNAME']}") + return ENV['JUPYTER_DEV_USERNAME'] + end + + validate! + + uri = hub_user_uri + + Rails.logger.info("[JupyterIdentityFetcher] Fetching identity from #{uri}") + + request = Net::HTTP::Get.new(uri) + request['Accept'] = 'application/json' + request['Authorization'] = "token #{@token}" + + response = Net::HTTP.start( + uri.hostname, + uri.port, + use_ssl: uri.scheme == 'https', + open_timeout: 10, + read_timeout: 30 + ) do |http| + http.request(request) + end + + unless response.is_a?(Net::HTTPSuccess) + raise IdentityError, "JupyterHub identity lookup returned HTTP #{response.code}: #{response.body}" + end + + model = JSON.parse(response.body) + name = model['name'] || model['username'] + + raise IdentityError, 'JupyterHub identity response did not include a username.' if name.blank? + + name + rescue JSON::ParserError => e + raise IdentityError, "JupyterHub identity response was not valid JSON: #{e.message}" + rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise IdentityError, "Could not connect to JupyterHub identity endpoint: #{e.message}" + end + + private + + def validate! + raise IdentityError, 'Missing Jupyter token.' if @token.blank? + end + + def hub_user_uri + hub_origin = ENV.fetch('JUPYTERHUB_API_ORIGIN', nil).presence || @origin + hub_origin = hub_origin.to_s.strip.sub(%r{/*\z}, '') + + URI.parse("#{hub_origin}/hub/api/user") + end + + def normalize_origin(origin) + overridden = ENV.fetch('JUPYTER_FETCH_ORIGIN', nil) + value = overridden.presence || origin.to_s + + value.strip.sub(%r{/*\z}, '') + end + + def normalize_base_url(base_url) + value = base_url.to_s.strip + + return '/' if value.blank? + + if value.start_with?('http://', 'https://') + parsed = URI.parse(value) + value = parsed.path.presence || '/' + end + + value = "/#{value}" unless value.start_with?('/') + value = "#{value}/" unless value.end_with?('/') + + value + rescue URI::InvalidURIError + '/' + end +end \ No newline at end of file diff --git a/app/services/jupyter_notebook_fetcher.rb b/app/services/jupyter_notebook_fetcher.rb new file mode 100644 index 0000000000..4a66fd649f --- /dev/null +++ b/app/services/jupyter_notebook_fetcher.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require 'json' +require 'net/http' +require 'uri' + +class JupyterNotebookFetcher + class FetchError < StandardError; end + + def initialize(origin:, base_url:, token:, notebook_path:) + @origin = normalize_origin(origin) + @base_url = normalize_base_url(base_url) + @token = token.to_s + @notebook_path = notebook_path.to_s + end + + def fetch + validate! + + uri = contents_uri + + Rails.logger.info("[JupyterNotebookFetcher] origin=#{@origin}") + Rails.logger.info("[JupyterNotebookFetcher] base_url=#{@base_url}") + Rails.logger.info("[JupyterNotebookFetcher] notebook_path=#{@notebook_path}") + Rails.logger.info("[JupyterNotebookFetcher] Fetching #{uri}") + + request = Net::HTTP::Get.new(uri) + request['Accept'] = 'application/json' + request['Authorization'] = "token #{@token}" if @token.present? + + response = Net::HTTP.start( + uri.hostname, + uri.port, + use_ssl: uri.scheme == 'https', + open_timeout: 10, + read_timeout: 30 + ) do |http| + http.request(request) + end + + unless response.is_a?(Net::HTTPSuccess) + raise FetchError, "Jupyter returned HTTP #{response.code} for #{uri}: #{response.body}" + end + + model = JSON.parse(response.body) + + { + name: model['name'], + path: model['path'], + type: model['type'], + format: model['format'], + mimetype: model['mimetype'], + writable: model['writable'], + content: model['content'] + } + rescue JSON::ParserError => e + raise FetchError, "Jupyter returned invalid JSON: #{e.message}" + rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout, Net::ReadTimeout => e + raise FetchError, "Could not connect to Jupyter: #{e.message}" + end + + private + + def validate! + raise FetchError, 'Missing Jupyter origin.' if @origin.blank? + raise FetchError, 'Missing Jupyter notebook path.' if @notebook_path.blank? + raise FetchError, 'Missing Jupyter token.' if @token.blank? + end + + def contents_uri + encoded_path = @notebook_path + .split('/') + .reject(&:blank?) + .map { |part| URI.encode_www_form_component(part) } + .join('/') + + base = "#{@origin}#{@base_url}" + base = "#{base}/" unless base.end_with?('/') + + uri = URI.parse("#{base}api/contents/#{encoded_path}") + uri.query = URI.encode_www_form(content: '1') + uri + end + + def normalize_origin(origin) + # Browser/Jupyter may report origin as http://localhost:8889. + # From inside the MarkUs Docker container, localhost means the container itself, + # so for local Docker testing we override it with: + # JUPYTER_FETCH_ORIGIN=http://host.docker.internal:8889 + overridden = ENV.fetch('JUPYTER_FETCH_ORIGIN', nil) + value = overridden.presence || origin.to_s + + value.strip.sub(%r{/*\z}, '') + end + + def normalize_base_url(base_url) + value = base_url.to_s.strip + + return '/' if value.blank? + + # Sometimes the extension/Jupyter may send a full URL here, for example: + # http://localhost:8889/ + # In that case, use only the path part, usually "/". + if value.start_with?('http://', 'https://') + parsed = URI.parse(value) + value = parsed.path.presence || '/' + end + + value = "/#{value}" unless value.start_with?('/') + value = "#{value}/" unless value.end_with?('/') + + value + rescue URI::InvalidURIError + '/' + end +end \ No newline at end of file diff --git a/app/services/jupyter_submission_writer.rb b/app/services/jupyter_submission_writer.rb new file mode 100644 index 0000000000..04146be040 --- /dev/null +++ b/app/services/jupyter_submission_writer.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require 'tempfile' + +# Writes a fetched Jupyter file into the student's MarkUs repository using the +# same helper methods used by the normal MarkUs web submission flow. +class JupyterSubmissionWriter + include RepositoryHelper + + WriteError = Class.new(StandardError) + + def initialize(assignment:, grouping:, current_role:, current_user:, destination_path:, filename:, content:, content_type:) + @assignment = assignment + @grouping = grouping + @current_role = current_role + @current_user = current_user + @destination_path = destination_path.to_s + @filename = filename.to_s + @content = content.to_s + @content_type = content_type.presence || 'application/octet-stream' + end + + def write! + validate_destination_path! + + path_inside_assignment = File.dirname(@destination_path) + path_inside_assignment = '' if path_inside_assignment == '.' + + repo_path = FileHelper.checked_join(@assignment.repository_folder, path_inside_assignment) + raise WriteError, I18n.t('errors.invalid_path') if repo_path.nil? + + path = Pathname.new(repo_path) + revision_identifier = nil + + @grouping.access_repo do |repo| + txn = repo.get_transaction(@current_user.user_name) + uploaded_file = build_uploaded_file + + required_files = if @current_role.student? && @assignment.only_required_files + @assignment.assignment_files.pluck(:filename).map do |name| + File.join(@assignment.repository_folder, name) + end + end + + success, messages = add_file( + uploaded_file, + @current_role, + repo, + path: path, + txn: txn, + check_size: true, + required_files: required_files + ) + + raise WriteError, messages.join(', ') unless success + + commit_success, commit_message = commit_transaction(repo, txn) + raise WriteError, commit_message unless commit_success + + revision_identifier = repo.get_latest_revision.revision_identifier.to_s + ensure + uploaded_file&.tempfile&.close! + end + + revision_identifier + rescue StandardError => e + raise e if e.is_a?(WriteError) + + raise WriteError, e.message + end + + private + + def validate_destination_path! + raise WriteError, 'Destination path is missing.' if @destination_path.blank? + + cleaned = Pathname.new(@destination_path).cleanpath.to_s + if cleaned.start_with?('../') || cleaned == '..' || cleaned.start_with?('/') + raise WriteError, 'Invalid destination path.' + end + + @destination_path = cleaned + @filename = File.basename(cleaned) + raise WriteError, 'Destination filename is missing.' if @filename.blank? + end + + def build_uploaded_file + sanitized_filename = FileHelper.sanitize_file_name(@filename) + tempfile = Tempfile.new(['jupyter_submission', File.extname(sanitized_filename)]) + tempfile.binmode + tempfile.write(@content) + tempfile.rewind + + ActionDispatch::Http::UploadedFile.new( + filename: sanitized_filename, + tempfile: tempfile, + type: @content_type + ) + end +end diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index b6b50b03b5..9c89f09a8e 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -4,14 +4,20 @@ # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests. # Read more: https://github.com/cyu/rack-cors +# frozen_string_literal: true Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - # This enables jupyterhub servers running the markus-jupyter-extension to submit files to MarkUs - # Add hosts running jupyterhub to the Settings.jupyter_server.hosts settings option. - origins(*Settings.jupyter_server.hosts) - resource %r{/api/courses/\d+/assignments/\d+/submit_file}, + origins 'http://localhost:8889', 'http://127.0.0.1:8889' + + resource '/api/jupyter_submissions', + headers: :any, + methods: [:post, :options], + credentials: true + + resource '/csc108/api/jupyter_submissions', headers: :any, - methods: :post + methods: [:post, :options], + credentials: true end -end +end \ No newline at end of file diff --git a/config/initializers/cors_jupyter_submissions_example.rb b/config/initializers/cors_jupyter_submissions_example.rb new file mode 100644 index 0000000000..d04c5291bb --- /dev/null +++ b/config/initializers/cors_jupyter_submissions_example.rb @@ -0,0 +1,13 @@ +# Optional development-only example. +# Use only if the JupyterLab frontend is blocked by CORS when posting to MarkUs. +# Prefer a stricter production configuration with a known JupyterHub origin. +# +# Rails.application.config.middleware.insert_before 0, Rack::Cors do +# allow do +# origins 'http://localhost:8888', 'http://localhost:8889', 'http://127.0.0.1:8888', 'http://127.0.0.1:8889' +# resource '/api/jupyter_submissions', +# headers: :any, +# methods: [:post, :options], +# credentials: true +# end +# end diff --git a/config/routes.rb b/config/routes.rb index 51c1de0ca1..f5491a9e05 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -562,6 +562,8 @@ # optional path scope (denoted by the parentheses) # API routes namespace :api do + post 'jupyter_submissions', to: 'jupyter_submissions#create' + resources :users, only: [:index, :create, :show, :update] do collection do put 'update_by_username' diff --git a/config/routes_addition.rb b/config/routes_addition.rb new file mode 100644 index 0000000000..bda206ee58 --- /dev/null +++ b/config/routes_addition.rb @@ -0,0 +1,8 @@ +# Add this route in config/routes.rb. +# Recommended placement: inside the course/assignment scope if your MarkUs routes already nest submissions under courses and assignments. +# Minimal API-style route used by the JupyterLab extension: + +post 'api/jupyter_submissions', to: 'api/jupyter_submissions#create' + +# Alternative nested route if you prefer course/assignment IDs in the URL: +# post 'courses/:course_id/assignments/:assignment_id/jupyter_submit', to: 'api/jupyter_submissions#create' From b39bb0c643cfc9a0c27022daec0ad532c9ab01c1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 16:03:36 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- app/controllers/api/jupyter_submissions_controller.rb | 6 +++--- app/services/jupyter_identity_fetcher.rb | 6 +++--- app/services/jupyter_notebook_fetcher.rb | 2 +- app/services/jupyter_submission_writer.rb | 6 ++++-- config/initializers/cors.rb | 2 +- config/routes.rb | 2 +- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/app/controllers/api/jupyter_submissions_controller.rb b/app/controllers/api/jupyter_submissions_controller.rb index cce830c194..4862d05eda 100644 --- a/app/controllers/api/jupyter_submissions_controller.rb +++ b/app/controllers/api/jupyter_submissions_controller.rb @@ -175,7 +175,7 @@ def find_course_from_payload(payload) return Course.find(course_id) if course_id.present? course_key = payload['course'].to_s.strip - return nil if course_key.blank? + return if course_key.blank? return Course.find(course_key) if integer_string?(course_key) @@ -225,7 +225,7 @@ def resolve_jupyter_username!(jupyter_info) # This is useful when testing with standalone JupyterLab, where the token # authenticates the server but does not identify a real JupyterHub user. if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? - return ENV['JUPYTER_DEV_USERNAME'] + return ENV.fetch('JUPYTER_DEV_USERNAME', nil) end JupyterIdentityFetcher.new( @@ -331,4 +331,4 @@ def integer_string?(value) value.to_s.match?(/\A\d+\z/) end end -end \ No newline at end of file +end diff --git a/app/services/jupyter_identity_fetcher.rb b/app/services/jupyter_identity_fetcher.rb index 90e3397626..82b877aeb2 100644 --- a/app/services/jupyter_identity_fetcher.rb +++ b/app/services/jupyter_identity_fetcher.rb @@ -17,8 +17,8 @@ def username # Local development fallback only. # This allows standalone JupyterLab testing where there is no JupyterHub identity endpoint. if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? - Rails.logger.info("[JupyterIdentityFetcher] Using development username #{ENV['JUPYTER_DEV_USERNAME']}") - return ENV['JUPYTER_DEV_USERNAME'] + Rails.logger.info("[JupyterIdentityFetcher] Using development username #{ENV.fetch('JUPYTER_DEV_USERNAME', nil)}") + return ENV.fetch('JUPYTER_DEV_USERNAME', nil) end validate! @@ -94,4 +94,4 @@ def normalize_base_url(base_url) rescue URI::InvalidURIError '/' end -end \ No newline at end of file +end diff --git a/app/services/jupyter_notebook_fetcher.rb b/app/services/jupyter_notebook_fetcher.rb index 4a66fd649f..d13cf23a8c 100644 --- a/app/services/jupyter_notebook_fetcher.rb +++ b/app/services/jupyter_notebook_fetcher.rb @@ -113,4 +113,4 @@ def normalize_base_url(base_url) rescue URI::InvalidURIError '/' end -end \ No newline at end of file +end diff --git a/app/services/jupyter_submission_writer.rb b/app/services/jupyter_submission_writer.rb index 04146be040..5db502c132 100644 --- a/app/services/jupyter_submission_writer.rb +++ b/app/services/jupyter_submission_writer.rb @@ -7,9 +7,11 @@ class JupyterSubmissionWriter include RepositoryHelper - WriteError = Class.new(StandardError) + class WriteError < StandardError + end - def initialize(assignment:, grouping:, current_role:, current_user:, destination_path:, filename:, content:, content_type:) + def initialize(assignment:, grouping:, current_role:, current_user:, destination_path:, filename:, content:, + content_type:) @assignment = assignment @grouping = grouping @current_role = current_role diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 9c89f09a8e..2dbc42f2cd 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -20,4 +20,4 @@ methods: [:post, :options], credentials: true end -end \ No newline at end of file +end diff --git a/config/routes.rb b/config/routes.rb index f5491a9e05..64247364ff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -563,7 +563,7 @@ # API routes namespace :api do post 'jupyter_submissions', to: 'jupyter_submissions#create' - + resources :users, only: [:index, :create, :show, :update] do collection do put 'update_by_username' From 03a016161b7f58ec217b8c533058eee9b2d66bd9 Mon Sep 17 00:00:00 2001 From: Tianshu Luan Date: Tue, 16 Jun 2026 12:19:28 -0400 Subject: [PATCH 3/4] Revised with respect to the review suggestions. --- .../jupyter/jupyter_submissions_controller.rb | 271 ++++++++++++++++++ app/services/jupyter_identity_fetcher.rb | 31 +- app/services/jupyter_submission_writer.rb | 2 - compose.yaml | 3 + config/initializers/cors.rb | 15 +- .../cors_jupyter_submissions_example.rb | 13 - config/routes.rb | 5 +- config/routes_addition.rb | 8 - 8 files changed, 304 insertions(+), 44 deletions(-) create mode 100644 app/controllers/jupyter/jupyter_submissions_controller.rb delete mode 100644 config/initializers/cors_jupyter_submissions_example.rb delete mode 100644 config/routes_addition.rb diff --git a/app/controllers/jupyter/jupyter_submissions_controller.rb b/app/controllers/jupyter/jupyter_submissions_controller.rb new file mode 100644 index 0000000000..547381c7af --- /dev/null +++ b/app/controllers/jupyter/jupyter_submissions_controller.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require 'tempfile' + +module Jupyter + class SubmissionsController < ApplicationController + # Local prototype only. + # For production, replace these skips with proper MarkUs/JupyterHub authorization. + skip_before_action :verify_authenticity_token, only: [:submit], raise: false + skip_verify_authorized only: :submit + + def submit + payload = params + + jupyter_info = payload['jupyter'] || {} + jupyter_path = payload['notebook_path'] + + destination_path = sanitize_destination_path( + payload['destination_path'].presence || + payload['notebook_name'].presence || + File.basename(jupyter_path.to_s) + ) + + user = find_user_from_jupyter_token!(jupyter_info) + course = find_course_from_payload!(payload) + + student = course.roles.find_by(user_id: user.id) + + if student.nil? + raise ActiveRecord::RecordNotFound, + t( + 'jupyter.submissions.errors.student_role_not_found', + user_name: user.user_name.inspect, + course_id: course.id + ) + end + + assignment = find_assignment_from_payload!(payload, course) + + grouping = if student.has_accepted_grouping_for?(assignment.id) + student.accepted_grouping_for(assignment.id) + elsif assignment.group_max == 1 + student.create_group_for_working_alone_student(assignment.id) + student.accepted_grouping_for(assignment.id) + else + student.create_autogenerated_name_group(assignment) + end + + jupyter_file = JupyterNotebookFetcher.new( + origin: jupyter_info['origin'], + base_url: jupyter_info['base_url'], + token: jupyter_info['token'], + notebook_path: jupyter_path + ).fetch + + submitted_file = tempfile_from_jupyter_file!( + jupyter_file: jupyter_file, + destination_path: destination_path + ) + + Rails.logger.info( + "[Jupyter::SubmissionsController] Submitting #{destination_path} " \ + "for user=#{user.user_name}, " \ + "course_id=#{course.id}, " \ + "assignment_id=#{assignment.id}, grouping_id=#{grouping.id}" + ) + + submit_jupyter_file!( + grouping: grouping, + assignment: assignment, + path: destination_path, + file: submitted_file + ) + + render json: { + status: 'success', + message: t('jupyter.submissions.submit.success'), + submitted_file: destination_path, + markus_target: { + course_id: course.id, + assignment_id: assignment.id, + assignment: assignment.short_identifier, + repository_folder: assignment.repository_folder, + grouping_id: grouping.id, + group_id: grouping.group_id, + student_role_id: student.id, + markus_user_name: user.user_name + }, + fetched_file: { + name: jupyter_file[:name], + path: jupyter_file[:path], + type: jupyter_file[:type], + format: jupyter_file[:format] + } + }, status: :ok + rescue JupyterIdentityFetcher::IdentityError => e + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :unauthorized + rescue JupyterNotebookFetcher::FetchError => e + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :bad_gateway + rescue ActiveRecord::RecordNotFound, ArgumentError => e + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :not_found + rescue StandardError => e + Rails.logger.error("[Jupyter::SubmissionsController] #{e.class}: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) if e.backtrace + + render json: { + status: 'error', + message: e.message, + error_class: e.class.name + }, status: :internal_server_error + ensure + submitted_file&.close + submitted_file&.unlink + end + + private + + def find_user_from_jupyter_token!(jupyter_info) + username = resolve_jupyter_username!(jupyter_info) + + Rails.logger.info( + "[Jupyter::SubmissionsController] Jupyter token resolved to username=#{username}" + ) + + user = User.find_by(user_name: username) + + if user.nil? + raise ActiveRecord::RecordNotFound, + t( + 'jupyter.submissions.errors.user_not_found', + user_name: username.inspect + ) + end + + user + end + + def find_course_from_payload!(payload) + if payload['course_id'].present? + return Course.find(payload['course_id']) + end + + course_name = payload['course'].to_s.strip + + if course_name.blank? + raise ActiveRecord::RecordNotFound, + t('jupyter.submissions.errors.missing_course') + end + + course = Course.find_by(name: course_name) + + if course.nil? + raise ActiveRecord::RecordNotFound, + t( + 'jupyter.submissions.errors.course_not_found', + course: course_name.inspect + ) + end + + course + end + + def find_assignment_from_payload!(payload, course) + assignment = if payload['assignment_id'].present? + course.assignments.find_by(id: payload['assignment_id']) + elsif payload['assignment'].present? + course.assignments.find_by( + short_identifier: payload['assignment'].to_s.strip + ) + else + raise ActiveRecord::RecordNotFound, + t('jupyter.submissions.errors.missing_assignment') + end + + return assignment if assignment.present? + + assignment_value = payload['assignment_id'].presence || payload['assignment'].presence + + raise ActiveRecord::RecordNotFound, + t( + 'jupyter.submissions.errors.assignment_not_found', + assignment: assignment_value.inspect, + course_id: course.id + ) + end + + def resolve_jupyter_username!(jupyter_info) + # Local development fallback only. + # This is useful when testing with standalone JupyterLab, where the token + # authenticates the server but does not identify a real JupyterHub user. + if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? + return ENV['JUPYTER_DEV_USERNAME'] + end + + JupyterIdentityFetcher.new( + origin: jupyter_info['origin'], + base_url: jupyter_info['base_url'], + token: jupyter_info['token'] + ).username + end + + def tempfile_from_jupyter_file!(jupyter_file:, destination_path:) + submitted_file = Tempfile.new( + ['jupyter-submission', File.extname(destination_path)] + ) + + submitted_file.binmode + submitted_file.write(jupyter_file[:content]) + submitted_file.rewind + submitted_file + end + + def submit_jupyter_file!(grouping:, assignment:, path:, file:) + sanitized_path = sanitize_destination_path(path) + + if assignment.only_required_files + required_file = assignment.assignment_files.find_by(filename: sanitized_path) + + if required_file.nil? + raise ArgumentError, + t( + 'jupyter.submissions.errors.required_file_missing', + filename: sanitized_path.inspect + ) + end + end + + # TODO: + # Replace this block with the exact MarkUs repository write call. + # + # The reviewer asked not to reuse SubmissionsHelper#upload_file because it does + # too many API-specific things. This method should only: + # 1. validate the submitted path, + # 2. check required files when assignment.only_required_files is true, + # 3. add the raw tempfile content to the grouping repository. + # + # Check the existing SubmissionsHelper#upload_file implementation and copy only + # the lower-level repository write part. + grouping.access_repo do |repo| + repo.add_file( + sanitized_path, + file.read, + "Submit #{sanitized_path} from Jupyter" + ) + end + end + + def sanitize_destination_path(path) + filename = File.basename(path.to_s.strip) + + if filename.blank? + raise ArgumentError, + t('jupyter.submissions.errors.destination_filename_missing') + end + + filename + end + end +end diff --git a/app/services/jupyter_identity_fetcher.rb b/app/services/jupyter_identity_fetcher.rb index 82b877aeb2..278cb1c404 100644 --- a/app/services/jupyter_identity_fetcher.rb +++ b/app/services/jupyter_identity_fetcher.rb @@ -16,9 +16,17 @@ def initialize(origin:, base_url:, token:) def username # Local development fallback only. # This allows standalone JupyterLab testing where there is no JupyterHub identity endpoint. +<<<<<<< HEAD if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? Rails.logger.info("[JupyterIdentityFetcher] Using development username #{ENV.fetch('JUPYTER_DEV_USERNAME', nil)}") return ENV.fetch('JUPYTER_DEV_USERNAME', nil) +======= + if Rails.env.development? && Settings.jupyter_server.dev_username.present? + Rails.logger.info( + "[JupyterIdentityFetcher] Using development username #{Settings.jupyter_server.dev_username}" + ) + return Settings.jupyter_server.dev_username +>>>>>>> 95066dd62 (Revised files based on review suggestions.) end validate! @@ -42,19 +50,25 @@ def username end unless response.is_a?(Net::HTTPSuccess) - raise IdentityError, "JupyterHub identity lookup returned HTTP #{response.code}: #{response.body}" + raise IdentityError, + "JupyterHub identity lookup returned HTTP #{response.code}: #{response.body}" end model = JSON.parse(response.body) name = model['name'] || model['username'] - raise IdentityError, 'JupyterHub identity response did not include a username.' if name.blank? + if name.blank? + raise IdentityError, + 'JupyterHub identity response did not include a username.' + end name rescue JSON::ParserError => e - raise IdentityError, "JupyterHub identity response was not valid JSON: #{e.message}" + raise IdentityError, + "JupyterHub identity response was not valid JSON: #{e.message}" rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout, Net::ReadTimeout => e - raise IdentityError, "Could not connect to JupyterHub identity endpoint: #{e.message}" + raise IdentityError, + "Could not connect to JupyterHub identity endpoint: #{e.message}" end private @@ -64,15 +78,12 @@ def validate! end def hub_user_uri - hub_origin = ENV.fetch('JUPYTERHUB_API_ORIGIN', nil).presence || @origin - hub_origin = hub_origin.to_s.strip.sub(%r{/*\z}, '') - - URI.parse("#{hub_origin}/hub/api/user") + URI.parse("#{@origin}/hub/api/user") end def normalize_origin(origin) - overridden = ENV.fetch('JUPYTER_FETCH_ORIGIN', nil) - value = overridden.presence || origin.to_s + configured_origin = Settings.jupyter_server.api_origin.presence + value = configured_origin || origin.to_s value.strip.sub(%r{/*\z}, '') end diff --git a/app/services/jupyter_submission_writer.rb b/app/services/jupyter_submission_writer.rb index 5db502c132..ecfe040924 100644 --- a/app/services/jupyter_submission_writer.rb +++ b/app/services/jupyter_submission_writer.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'tempfile' - # Writes a fetched Jupyter file into the student's MarkUs repository using the # same helper methods used by the normal MarkUs web submission flow. class JupyterSubmissionWriter diff --git a/compose.yaml b/compose.yaml index b1440c7672..9f93885266 100644 --- a/compose.yaml +++ b/compose.yaml @@ -42,6 +42,7 @@ services: command: bundle exec rails server -b 0.0.0.0 environment: - RAILS_RELATIVE_URL_ROOT=/csc108 + - JUPYTER_FETCH_ORIGIN=http://host.docker.internal:8889 - PGDATABASE=markus_development - PGDATABASETEST=markus_test - MARKUS_URL=http://host.docker.internal:3000 # used by the tasks in autotest.rake @@ -49,8 +50,10 @@ services: - CAPYBARA_SERVER_PORT=${CAPYBARA_SERVER_PORT:-3434} - CAPYBARA_SERVER_HOST=${CAPYBARA_SERVER_HOST:-0.0.0.0} - PLAYWRIGHT_BROWSERS_PATH=/app/tmp/pw-browsers + - JUPYTER_DEV_USERNAME=c5anthei extra_hosts: - localhost:host-gateway + - "host.docker.internal:host-gateway" ports: - '3000:3000' volumes: diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb index 2dbc42f2cd..4effac351a 100644 --- a/config/initializers/cors.rb +++ b/config/initializers/cors.rb @@ -8,16 +8,11 @@ Rails.application.config.middleware.insert_before 0, Rack::Cors do allow do - origins 'http://localhost:8889', 'http://127.0.0.1:8889' - - resource '/api/jupyter_submissions', - headers: :any, - methods: [:post, :options], - credentials: true - - resource '/csc108/api/jupyter_submissions', + # This enables jupyterhub servers running the markus-jupyter-extension to submit files to MarkUs + # Add hosts running jupyterhub to the Settings.jupyter_server.hosts settings option. + origins(*Settings.jupyter_server.hosts) + resource %r{/api/courses/\d+/assignments/\d+/submit_file}, headers: :any, - methods: [:post, :options], - credentials: true + methods: [:post] end end diff --git a/config/initializers/cors_jupyter_submissions_example.rb b/config/initializers/cors_jupyter_submissions_example.rb deleted file mode 100644 index d04c5291bb..0000000000 --- a/config/initializers/cors_jupyter_submissions_example.rb +++ /dev/null @@ -1,13 +0,0 @@ -# Optional development-only example. -# Use only if the JupyterLab frontend is blocked by CORS when posting to MarkUs. -# Prefer a stricter production configuration with a known JupyterHub origin. -# -# Rails.application.config.middleware.insert_before 0, Rack::Cors do -# allow do -# origins 'http://localhost:8888', 'http://localhost:8889', 'http://127.0.0.1:8888', 'http://127.0.0.1:8889' -# resource '/api/jupyter_submissions', -# headers: :any, -# methods: [:post, :options], -# credentials: true -# end -# end diff --git a/config/routes.rb b/config/routes.rb index 64247364ff..2195c95d05 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -562,7 +562,6 @@ # optional path scope (denoted by the parentheses) # API routes namespace :api do - post 'jupyter_submissions', to: 'jupyter_submissions#create' resources :users, only: [:index, :create, :show, :update] do collection do @@ -1097,3 +1096,7 @@ match '*path', controller: 'main', action: 'page_not_found', via: :all end + +namespace :jupyter do + post 'submit', to: 'submissions#submit' +end diff --git a/config/routes_addition.rb b/config/routes_addition.rb deleted file mode 100644 index bda206ee58..0000000000 --- a/config/routes_addition.rb +++ /dev/null @@ -1,8 +0,0 @@ -# Add this route in config/routes.rb. -# Recommended placement: inside the course/assignment scope if your MarkUs routes already nest submissions under courses and assignments. -# Minimal API-style route used by the JupyterLab extension: - -post 'api/jupyter_submissions', to: 'api/jupyter_submissions#create' - -# Alternative nested route if you prefer course/assignment IDs in the URL: -# post 'courses/:course_id/assignments/:assignment_id/jupyter_submit', to: 'api/jupyter_submissions#create' From 5cd6f25bfa2c7a60b818e361d788b40f91c9a403 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:24:31 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- app/controllers/jupyter/jupyter_submissions_controller.rb | 2 +- config/routes.rb | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/controllers/jupyter/jupyter_submissions_controller.rb b/app/controllers/jupyter/jupyter_submissions_controller.rb index 547381c7af..0359f84193 100644 --- a/app/controllers/jupyter/jupyter_submissions_controller.rb +++ b/app/controllers/jupyter/jupyter_submissions_controller.rb @@ -201,7 +201,7 @@ def resolve_jupyter_username!(jupyter_info) # This is useful when testing with standalone JupyterLab, where the token # authenticates the server but does not identify a real JupyterHub user. if Rails.env.development? && ENV['JUPYTER_DEV_USERNAME'].present? - return ENV['JUPYTER_DEV_USERNAME'] + return ENV.fetch('JUPYTER_DEV_USERNAME', nil) end JupyterIdentityFetcher.new( diff --git a/config/routes.rb b/config/routes.rb index 2195c95d05..dc14c4a26e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -562,7 +562,6 @@ # optional path scope (denoted by the parentheses) # API routes namespace :api do - resources :users, only: [:index, :create, :show, :update] do collection do put 'update_by_username'