Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New/numbas backend changes #433

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/api/api_root.rb
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ class ApiRoot < Grape::API
mount Tii::TiiGroupAttachmentApi
mount Tii::TiiActionApi


mount NumbasApi
mount TestAttemptsApi
mount CampusesPublicApi
mount CampusesAuthenticatedApi
mount TutorialsApi
Expand Down Expand Up @@ -122,6 +125,8 @@ class ApiRoot < Grape::API
AuthenticationHelpers.add_auth_to UnitRolesApi
AuthenticationHelpers.add_auth_to UnitsApi
AuthenticationHelpers.add_auth_to WebcalApi
AuthenticationHelpers.add_auth_to NumbasApi
AuthenticationHelpers.add_auth_to TestAttemptsApi

add_swagger_documentation \
base_path: nil,
Expand Down
7 changes: 7 additions & 0 deletions app/api/entities/numbas_entity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@

module Entities
class NumbasEntity < Grape::Entity
expose :file_content, documentation: { type: 'string', desc: 'File content' }
expose :content_type, documentation: { type: 'string', desc: 'Content type' }
end
end
8 changes: 8 additions & 0 deletions app/api/entities/test_attempt_entity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

module Entities
class TestAttemptEntity < Grape::Entity
expose :id, :name, :attempt_number, :pass_status, :exam_data, :completed, :cmi_entry
expose :task_id, as: :associated_task_id
expose :exam_result, :attempted_at
end
end
79 changes: 79 additions & 0 deletions app/api/numbas_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
require 'grape'
require 'zip'
require 'mime/types'
class NumbasApi < Grape::API
# Include the AuthenticationHelpers for authentication functionality
helpers AuthenticationHelpers

helpers do
# Method to stream a file from a zip archive at the specified path
# @param zip_path [String] the path to the zip archive
# @param file_path [String] the path of the file within the zip archive
def stream_file_from_zip(zip_path, file_path)
file_stream = nil

# Get an input stream for the requested file within the ZIP archive
Zip::File.open(zip_path) do |zip_file|
zip_file.each do |entry|
logger.debug "Entry name: #{entry.name}"
if entry.name == file_path
file_stream = entry.get_input_stream
break
end
end
end

# If the file was not found in the ZIP archive, return a 404 response
unless file_stream
error!({ error: 'File not found' }, 404)
end

# Set the content type based on the file extension
content_type = MIME::Types.type_for(file_path).first.content_type
logger.debug "Content type: #{content_type}"

# Set the content type header
header 'Content-Type', content_type

# Set cache control header to prevent caching
header 'Cache-Control', 'no-cache, no-store, must-revalidate'

# Set the body to the contents of the file_stream and return the response
body file_stream.read
end
end

# Define the API namespace
namespace :numbas_api do
# Use Grape's before hook to check authentication before processing any route
before do
authenticated?
end

get '/index.html' do
env['api.format'] = :txt
zip_path = FileHelper.get_numbas_test_path(params[:unit_code], params[:task_definition_id], 'numbas_test.zip')
stream_file_from_zip(zip_path, 'index.html')
end

get '*file_path' do
env['api.format'] = :txt
zip_path = FileHelper.get_numbas_test_path(params[:unit_code], params[:task_definition_id], 'numbas_test.zip')
requested_file_path = "#{params[:file_path]}.#{params[:format]}"
stream_file_from_zip(zip_path, requested_file_path)
end

post '/uploadNumbasTest' do
# Ensure the uploaded file is present
unless params[:file] && params[:file][:tempfile]
error!({ error: 'File upload is missing' }, 400)
end

# Use the FileHelper to save the uploaded test
save_path = FileHelper.get_numbas_test_path(params[:unit_code], params[:task_definition_id], 'numbas_test.zip')
File.binwrite(save_path, params[:file][:tempfile].read)

{ success: true, message: 'File uploaded successfully' }
end
end
end
159 changes: 159 additions & 0 deletions app/api/test_attempts_api.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
require 'grape'

class TestAttemptsApi < Grape::API
format :json

# Assigning AuthenticationHelpers
helpers AuthenticationHelpers

# Handle common exceptions
rescue_from :all do |e|
error!({ error: e.message }, 500)
end

# Specific exception handler for record not found
rescue_from ActiveRecord::RecordNotFound do |e|
error!({ error: e.message }, 404)
end

# Handling validation errors from Grape
rescue_from Grape::Exceptions::ValidationErrors do |e|
error!({ errors: e.full_messages }, 400)
end

# Define the TestAttemptEntity
class TestAttemptEntity < Grape::Entity
expose :id, :name, :attempt_number, :pass_status, :exam_data, :completed, :cmi_entry
expose :task_id, as: :associated_task_id
expose :exam_result, :attempted_at
end

# Fetch all test results
desc 'Get all test results'
get do
tests = TestAttempt.order(id: :desc)
present tests, with: TestAttemptEntity
end

# Get latest test or create a new one based on completion status
desc 'Get latest test or create a new one based on completion status'
get 'latest' do
test = TestAttempt.order(id: :desc).first

if test.nil?
test = TestAttempt.create!(
name: "Default Test",
attempt_number: 1,
pass_status: false,
exam_data: nil,
completed: false,
cmi_entry: 'ab-initio',
task_id: params[:task_id]
)
elsif test.completed
test = TestAttempt.create!(
name: "Default Test",
attempt_number: test.attempt_number + 1,
pass_status: false,
exam_data: nil,
completed: false,
cmi_entry: 'ab-initio',
task_id: params[:task_id]
)
else
test.update!(cmi_entry: 'resume')
end

present test, with: TestAttemptEntity
end

# Fetch the latest completed test result
desc 'Get the latest completed test result'
get 'completed-latest' do
test = TestAttempt.where(completed: true).order(id: :desc).first

if test.nil?
error!({ message: 'No completed tests found' }, 404)
else
present test, with: TestAttemptEntity
end
end

# Fetch a specific test result by ID
desc 'Get a specific test result'
params do
requires :id, type: String, desc: 'ID of the test'
end
get ':id' do
present TestAttempt.find(params[:id]), with: TestAttemptEntity
end

# Create a new test result entry
desc 'Create a new test result'
params do
requires :task_id, type: Integer, desc: 'ID of the associated task'
requires :name, type: String, desc: 'Name of the test'
requires :attempt_number, type: Integer, desc: 'Number of attempts'
requires :pass_status, type: Boolean, desc: 'Passing status'
optional :exam_data, type: String, desc: 'Data related to the exam'
requires :completed, type: Boolean, desc: 'Completion status'
optional :cmi_entry, type: String, desc: 'CMI Entry', default: "ab-initio"
optional :exam_result, type: String, desc: 'Result of the exam'
optional :attempted_at, type: DateTime, desc: 'Timestamp of the test attempt'
end
post do
test = TestAttempt.create!(declared(params))
present test, with: TestAttemptEntity
end

# Update the details of a specific test result
desc 'Update a test result'
params do
optional :name, type: String, desc: 'Name of the test'
optional :attempt_number, type: Integer, desc: 'Number of attempts'
optional :pass_status, type: Boolean, desc: 'Passing status'
optional :exam_data, type: String, desc: 'Data related to the exam'
optional :completed, type: Boolean, desc: 'Completion status'
optional :cmi_entry, type: String, desc: 'CMI Entry'
optional :task_id, type: Integer, desc: 'ID of the associated task'
end
put ':id' do
test = TestAttempt.find(params[:id])
test.update!(declared(params, include_missing: false))
present test, with: TestAttemptEntity
end

# Delete a specific test result by ID
desc 'Delete a test result'
params do
requires :id, type: String, desc: 'ID of the test'
end
delete ':id' do
TestAttempt.find(params[:id]).destroy!
end

# Update the exam_data of a specific test result
desc 'Update exam data for a test result'
params do
requires :id, type: String, desc: 'ID of the test'
end
put ':id/exam_data' do
test = TestAttempt.find_by(id: params[:id])

error!('Test not found', 404) unless test

# Treat the entire params as the data to be saved
exam_data = params.to_json

begin
JSON.parse(exam_data)
test.update!(exam_data: exam_data)
{ message: 'Exam data updated successfully', test: test }
rescue JSON::ParserError
error!('Invalid JSON provided', 400)
rescue StandardError => e
error!(e.message, 500)
end
end

end
36 changes: 36 additions & 0 deletions app/models/test_attempt.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class TestAttempt < ApplicationRecord
include ApplicationHelper
include LogHelper
include GradeHelper

belongs_to :task

def self.permissions
student_role_permissions = [
:create,
:view_own,
:delete_own
]

tutor_role_permissions = [
:create,
:view_own,
:delete_own
]

convenor_role_permissions = [
:create,
:view_own,
:delete_own
]

nil_role_permissions = []

{
student: student_role_permissions,
tutor: tutor_role_permissions,
convenor: convenor_role_permissions,
nil: nil_role_permissions
}
end
end
16 changes: 16 additions & 0 deletions db/migrate/20231205011842_create_test_attempts.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class CreateTestAttempts < ActiveRecord::Migration[7.0]
def change
create_table :test_attempts do |t|
t.references :task, foreign_key: true
t.string :name
t.integer :attempt_number, default: 1, null: false
t.boolean :pass_status
t.text :exam_data
t.boolean :completed, default: false
t.datetime :attempted_at
t.string :cmi_entry, default: "ab-initio"
t.string :exam_result
t.timestamps
end
end
end
19 changes: 19 additions & 0 deletions db/migrate/20231205011958_add_fields_to_task_def.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
class AddFieldsToTaskDef < ActiveRecord::Migration[7.0]
def change
change_table :task_definitions do |t|
t.boolean :has_test, default: false
t.boolean :restrict_attempts, default: false
t.integer :delay_restart_minutes
t.boolean :retake_on_resubmit, default: false
end
end

def down
change_table :task_definitions do |t|
t.remove :has_test
t.remove :restrict_attempts
t.remove :delay_restart_minutes
t.remove :retake_on_resubmit
end
end
end
22 changes: 21 additions & 1 deletion db/schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.0].define(version: 2023_06_03_064217) do
ActiveRecord::Schema[7.0].define(version: 2023_12_05_011958) do
create_table "activity_types", charset: "utf8", collation: "utf8_unicode_ci", force: :cascade do |t|
t.string "name", null: false
t.string "abbreviation", null: false
Expand Down Expand Up @@ -251,6 +251,10 @@
t.bigint "overseer_image_id"
t.string "tii_group_id"
t.string "moss_language"
t.boolean "has_test", default: false
t.boolean "restrict_attempts", default: false
t.integer "delay_restart_minutes"
t.boolean "retake_on_resubmit", default: false
t.index ["group_set_id"], name: "index_task_definitions_on_group_set_id"
t.index ["overseer_image_id"], name: "index_task_definitions_on_overseer_image_id"
t.index ["tutorial_stream_id"], name: "index_task_definitions_on_tutorial_stream_id"
Expand Down Expand Up @@ -347,6 +351,21 @@
t.index ["period", "year"], name: "index_teaching_periods_on_period_and_year", unique: true
end

create_table "test_attempts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.bigint "task_id"
t.string "name"
t.integer "attempt_number", default: 1, null: false
t.boolean "pass_status"
t.text "exam_data"
t.boolean "completed", default: false
t.datetime "attempted_at"
t.string "cmi_entry", default: "ab-initio"
t.string "exam_result"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["task_id"], name: "index_test_attempts_on_task_id"
end

create_table "tii_actions", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "entity_type"
t.bigint "entity_id"
Expand Down Expand Up @@ -535,4 +554,5 @@
t.index ["user_id"], name: "index_webcals_on_user_id", unique: true
end

add_foreign_key "test_attempts", "tasks"
end
Loading