diff --git a/.env b/.env index 80ed35459a4f7109c35ebd2fff11d6c5f1f0f60d..dbae7d31c3109a319779527d7ab3199e2be1cdd6 100755 --- a/.env +++ b/.env @@ -3,7 +3,6 @@ MARIADB_DATABASE=moodle MARIADB_USER=moodleuser MARIADB_PASSWORD=moodlepassword MOODLE_DATABASE_ROOT_PASSWORD=rootpassword -#MOODLE_DATABASE_HOST=localhost MOODLE_DATABASE_HOST=mariadb MOODLE_DATABASE_NAME=moodle MOODLE_DATABASE_USER=moodleuser diff --git a/Dockerfile b/Dockerfile index ab9d096ecffd678b60a4e77f0c246cd868eacd7f..35dacfd456c9e058b7feca5001297a5332f78a8e 100755 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ ENV MOODLE_BASE_DIR_DATA ${MOODLE_BASE_DIR_DATA} # Installing necessary packages RUN apt-get update && apt-get upgrade -y && \ - apt-get install -y apache2 php libapache2-mod-php php-mysqli php-mysql php-xml php-pdo php-pdo-mysql mariadb-client mariadb-server wget unzip p7zip-full python3 python3-pip iputils-ping php-mbstring graphviz aspell ghostscript clamav php8.2-pspell php8.2-curl php8.2-gd php8.2-intl php8.2-mysql php8.2-xml php8.2-xmlrpc php8.2-ldap php8.2-zip php8.2-soap php8.2-mbstring openssl git nano supervisor && \ + apt-get install -y apache2 php libapache2-mod-php php-mysqli php-mysql php-xml php-pdo php-pdo-mysql mariadb-client mariadb-server wget unzip p7zip-full python3 python3-pip iputils-ping php-mbstring graphviz aspell ghostscript clamav php8.2-pspell php8.2-curl php8.2-gd php8.2-intl php8.2-mysql php8.2-xml php8.2-xmlrpc php8.2-ldap php8.2-zip php8.2-soap php8.2-mbstring openssl git nano supervisor php-xdebug ca-certificates && \ apt-get clean && rm -rf /var/lib/apt/lists/* # Setting necessary php params @@ -26,6 +26,13 @@ RUN echo "mysql.default_socket=/run/mysqld/mysqld.sock" >> /etc/php/8.2/apache2/ RUN echo "max_input_vars = 5000" >> /etc/php/8.2/apache2/php.ini RUN echo "max_input_vars = 5000" >> /etc/php/8.2/cli/php.ini +# Setting Xdebug +#RUN echo "zend_extension=xdebug.so" >> /etc/php/8.2/apache2/php.ini +#RUN echo "xdebug.mode=debug" >> /etc/php/8.2/apache2/php.ini +#RUN echo "xdebug.start_with_request=yes" >> /etc/php/8.2/apache2/php.ini +#RUN echo "xdebug.client_host=host.docker.internal" >> /etc/php/8.2/apache2/php.ini +#RUN echo "xdebug.client_port=9003" >> /etc/php/8.2/apache2/php.ini + # Starting Apache server RUN chmod 1777 /tmp @@ -119,6 +126,18 @@ RUN openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -out /etc/ssl/certs/apache-selfsigned.crt \ -subj "/C=EU/ST=Berlin/L=Berlin/O=HFT/CN=www.moodle.loc" +# Установка корневых сертификатов +RUN update-ca-certificates + +RUN curl -O https://curl.se/ca/cacert.pem \ + && mv cacert.pem /etc/ssl/certs/cacert.pem \ + +# Настройка php.ini для OpenSSL +RUN echo "openssl.cafile=/etc/ssl/certs/ca-certificates.crt" >> /etc/php/8.2/cli/php.ini \ + && echo "openssl.capath=/etc/ssl/certs" >> /etc/php/8.2/cli/php.ini \ + && echo "openssl.cafile=/etc/ssl/certs/ca-certificates.crt" >> /etc/php/8.2/apache2/php.ini \ + && echo "openssl.capath=/etc/ssl/certs" >> /etc/php/8.2/apache2/php.ini \ + # Configure SSL virtual host RUN echo \ "\n" \ diff --git a/asystgrade/lib.php b/asystgrade/lib.php index f3b5014972e88058e7b93fb694e3ca2054236ac5..77a2be38a50018e5ce0504a0a264d38c3e083996 100755 --- a/asystgrade/lib.php +++ b/asystgrade/lib.php @@ -37,7 +37,7 @@ function local_asystgrade_before_footer() { global $PAGE; - // Получение параметров из URL + // Obtaining parameters from URL $qid = optional_param('qid', null, PARAM_INT); $slot = optional_param('slot', false, PARAM_INT); diff --git a/asystgrade/tests/quiz_api_test.php b/asystgrade/tests/quiz_api_test.php new file mode 100755 index 0000000000000000000000000000000000000000..0fec02d62d76816aa64c2ea744fd227132f444ac --- /dev/null +++ b/asystgrade/tests/quiz_api_test.php @@ -0,0 +1,399 @@ +libdir . '/phpunit/classes/advanced_testcase.php'); +require_once($CFG->dirroot . '/mod/quiz/locallib.php'); +require_once($CFG->dirroot . '/mod/quiz/tests/generator/lib.php'); + +class quiz_api_test extends advanced_testcase +{ + + /** + * @throws Exception + */ + public function test_quiz_api() + { + global $DB; + + $generator = $this->getDataGenerator(); + $quizgen = $generator->get_plugin_generator('mod_quiz'); + $questiongen = $generator->get_plugin_generator('core_question'); + + // Create a course + $coursegen = $generator->create_course([ + 'fullname' => 'Test Course', + 'shortname' => 'testcourse', + 'category' => 1, + ]); + + $teacher = $this->getDataGenerator()->create_user(); + $teacherRoleId = $DB->get_record('role', ['shortname' => 'teacher'])->id; + $this->getDataGenerator()->enrol_user($teacher->id, $coursegen->id, $teacherRoleId); + $this->setUser($teacher); + + // Create a quiz in the course + $quiz = $quizgen->create_instance([ + 'course' => $coursegen->id, + 'name' => 'Test Quiz', + 'intro' => 'This is a test quiz.', + 'attempts' => 1, + 'timeopen' => time(), + 'timeclose' => time() + 3600, + ]); + + // Create questions and answers + $questions = [ + [ + 'questiontext' => 'Warum kann Ihr Programm durch die Verwendung von Threads schneller werden, auch wenn Sie nur einen einzigen Prozessor zur Verfügung haben?', + 'qtype' => 'essay', + 'answers' => [ + 'Die Ausführung wird schneller, weil der Prozessor zwischen verschiedenen Teilaufgaben hin- und herspringen kann und so z.B. Wartezeiten auf Daten in einem Thread zur Bearbeitung anderer Threads genutzt werden können.' => 1 + ] + ], + [ + 'questiontext' => 'Warum wird Ihr Programm durch die Verwendung von Threads schneller, auch wenn Sie nur einen einzigen Prozessor zur Verfügung haben?', + 'qtype' => 'essay', + 'answers' => [ + 'Die Ausführung wird schneller, weil der Prozessor zwischen verschiedenen Teilaufgaben hin- und herspringen kann und so z.B. Wartezeiten auf Daten in einem Thread zur Bearbeitung anderer Threads genutzt werden können.' => 1 + ] + ], + [ + 'questiontext' => 'Beschreiben Sie die Struktur einer Stream-Pipeline. Woher kommen die Daten, was geschieht im Stream mit ihnen, wie endet der Stream? Geben Sie für jeden Streamabschnitt mindestens eine Beispielkomponente an.', + 'qtype' => 'essay', + 'answers' => [ + 'Datenquelle: Collections, Arrays, Generatoren (z.B. Datenbankabfragen, eigene Methoden). Verarbeitung: Filtern, Umformung, Begrenzung. Datensenke: Minimum / Maximum / Durchschnitt / Anzahl, Ausgabe in Collection oder Array / Reduktion / Auswertung.' => 1 + ] + ], + [ + 'questiontext' => 'Welche Auswirkungen hat die Model-View-Aufteilung bei Swing-Komponenten? Nennen Sie Beispiele anhand der Klasse JTable.', + 'qtype' => 'essay', + 'answers' => [ + 'Datenhaltung und Darstellung werden getrennt, so dass dieselben Daten flexibel dargestellt werden können. Datenhaltung findet in der Klasse TableModel statt: Welche Information steht in welcher Zelle? Information z.B. über die Editierbarkeit der Zellen, den zu verwendenden Editor und die Spaltenreihenfolge werden in JTable bzw. TableColumnModel gehalten.' => 3 + ] + ], + [ + 'questiontext' => 'Warum braucht man bei der Arbeit mit Threads Synchronisation?', + 'qtype' => 'essay', + 'answers' => [ + 'Man muss vermeiden, dass verschiedene Threads gleichzeitig auf Daten oder Objekte zugreifen, weil es dadurch zur Zerstörung von Werten und zu inkonsistenten Zuständen kommen kann.' => 1 + ] + ], + [ + 'questiontext' => 'Beantworten Sie kurz die 3 Fragen 1 - Was wird unter einem Thread verstanden / wann werden Threads benutzt ? (3 Pkt) 2 - Welche Ressourcen nutzt ein Thread exklusiv ? (2 Pkt) 3 - Welche Ressourcen teilen sich die Threads eines Programms ? (2 Pkt)', + 'qtype' => 'essay', + 'answers' => [ + 'Thread: 1 PT Ablauffaden, Ablaufeinheit, Ausführung etc. 1Pt Parallel, Quasi Parallel, Core, 1Pt Geschwindigkeit, Resourcenauslastung, parallele Ausführung' => 7 + ] + ], + ]; + + // Create a question category + $context = context_course::instance($coursegen->id); + $category = $this->create_question_category($context->id); + + foreach ($questions as $questiondata) { + // Create a question + $question = $questiongen->create_question($questiondata['qtype'], null, [ + 'category' => $category->id, // категория вопроса + 'questiontext' => [ + 'text' => $questiondata['questiontext'], + 'format' => FORMAT_HTML, + ], + 'name' => 'Test Question', + 'contextid' => $context->id, // Убедитесь, что контекст передается правильно + 'modifiedby' => $teacher->id, + ]); + + // Check the question ID + if (!$question) { + throw new Exception("Failed to create question."); + } + + // Add answers to the question + foreach ($questiondata['answers'] as $answertext => $fraction) { + // Check the validity of answer data + if (!is_string($answertext) || !is_numeric($fraction)) { + throw new InvalidArgumentException("Invalid answer format."); + } + error_log("Created answer: Answer Text: $answertext, Fraction: $fraction"); + + $answer = [ + 'question' => $question->id, + 'answer' => $answertext, + 'fraction' => $fraction, + 'feedback' => '', // можно добавить текст обратной связи + 'feedbackformat' => FORMAT_MOODLE, + ]; + $answer_id = $DB->insert_record('question_answers', $answer); + + // Check the answer ID + if (!$answer_id) { + throw new Exception("Failed to create answer."); + } + } + + error_log("Created question: " . print_r($questiondata, true)); + + // Get the current maximum slot for the quiz + $maxslot = $DB->get_field_sql(" + SELECT MAX(slot) + FROM {quiz_slots} + WHERE quizid = :quizid", ['quizid' => $quiz->id]); + + // Set the new slot + $newslot = $maxslot ? $maxslot + 1 : 1; + + // Define the number of questions per page + $questions_per_page = 1; + $current_page = floor(($newslot - 1) / $questions_per_page) + 1; + + // Check if a record already exists + $existing_slot = $DB->get_record('quiz_slots', [ + 'quizid' => $quiz->id, + 'slot' => $newslot, + 'page' => $current_page + ]); + + if ($existing_slot) { + echo "Slot and page combination already exists: Quiz ID: {$quiz->id}, Slot: {$newslot}, Page: {$current_page}\n"; + continue; // Skip adding this question + } + + // Manually add the question to the quiz via the quiz_slots table + $slotdata = [ + 'quizid' => $quiz->id, + 'questionid' => $question->id, + 'slot' => $newslot, // Set a new slot + 'page' => $current_page, // Set the current page + 'requireprevious' => 0, + 'maxmark' => 1.0, // Maximum mark for the question + ]; + + echo "Inserting into quiz_slots with Quiz ID: {$quiz->id}, Slot: {$newslot}, Page: {$current_page}\n"; + // Insert the data into the table + try { + $transaction = $DB->start_delegated_transaction(); + $DB->insert_record('quiz_slots', (object)$slotdata); + $transaction->allow_commit(); + } catch (dml_exception $e) { + $transaction->rollback($e); + throw new Exception("Failed to add question to quiz: " . $e->getMessage()); + } + } + + // Creating students + $students = []; + for ($i = 0; $i < 7; $i++) { + $students[] = $this->getDataGenerator()->create_user(); + } + + // Students course enrollment + foreach ($students as $student) { + $this->getDataGenerator()->enrol_user($student->id, $coursegen->id, 'student'); + } + + // Create attempts for students manually + $all_answers = array_map(function ($question) { + return array_keys($question['answers']); + }, $questions); + $flat_answers = array_merge(...$all_answers); + $questions_db = $DB->get_records('question', []); + var_dump($questions_db); + $question_keys = array_merge( + ...array_map(fn($question) => [ + $question->id => $question->questiontext + ], $questions_db) + ); + + $random_key = array_rand($question_keys); + var_dump($random_key, array_column($questions_db, 'id')[$random_key]); + $random_question_id = array_column($questions_db, 'id')[$random_key]; + + for ($i = 0; $i < count($students); $i++) { + $this->create_quiz_attempt($quiz->id, $students[$i]->id, $random_question_id, $flat_answers[$random_key], $i); + } + + global $DB; + $studentAnswers = []; + + // Logging the question ID + error_log("Question ID: $question->id"); + + // Logging the question text + error_log("Question Text: $question_keys[$random_key]"); + + $referenceAnswer = $flat_answers[$random_key]; + + $answers = $DB->get_records('question_attempt_step_data', ['name' => 'answer']); + // Logging the number of responses + error_log("Number of Answers: " . count($answers)); + + foreach ($answers as $answer) { + // Log each response + error_log("Answer: " . $answer->value); + + $studentAnswers[] = $answer->value; + var_dump($answer->value); + error_log("Answer: " . $answer->value); + } + + $apiendpoint = get_config('local_asystgrade', 'apiendpoint'); + if (!$apiendpoint) { + $apiendpoint = 'http://127.0.0.1:5000/api/autograde'; // Default setting + } + + error_log('APIendpoint: ' . $apiendpoint); + + try { + $httpClient = new http_client(); + $apiClient = client::getInstance($apiendpoint, $httpClient); + error_log('ApiClient initiated.'); + + error_log('Sending data to API and getting grade'); + $data = [ + 'referenceAnswer' => $referenceAnswer, + 'studentAnswers' => $studentAnswers + ]; + var_dump($data); + + error_log("Data to send to API: " . print_r($data, true)); + $response = $apiClient->send_data($data); + $grades = json_decode($response, true); + + error_log('Grade obtained: ' . print_r($grades, true)); + } catch (Exception $e) { + error_log('Error sending data to API: ' . $e->getMessage()); + return; + } + + var_dump($grades); + + // Check the result + $this->assertNotEmpty($grades); + $this->assertEquals($grades[0]['predicted_grade'], 'correct'); + $this->assertEquals($grades[6]['predicted_grade'], 'incorrect'); + } + + private function create_question_category($contextid) + { + global $DB; + + $category = [ + 'name' => 'Test Category', + 'contextid' => $contextid, + 'parent' => 0, + 'info' => '', + 'infoformat' => FORMAT_MOODLE, + ]; + // Use Moodle API to create a category + $categoryid = $DB->insert_record('question_categories', (object)$category); + + if (!$categoryid) { + throw new coding_exception("Failed to create category."); + } + + return $DB->get_record('question_categories', array('id' => $categoryid)); + } + + private function create_quiz_attempt($quizid, $userid, $questionid, $exapmle_answers, $student_id) + { + global $DB; + + $sql = "SELECT MAX(attempt) as max_attempt FROM {quiz_attempts} WHERE quiz = :quizid AND userid = :userid"; + $params = array('quizid' => $quizid, 'userid' => $userid); + $record = $DB->get_record_sql($sql, $params); + + // Set unique attempt number + $attempt_number = ($record && $record->max_attempt) ? $record->max_attempt + 1 : 1; + // Generate unique id for uniqueid + // This query takes the maximum uniqueid value from the table and adds 1 + $uniqueid = $DB->get_field_sql('SELECT COALESCE(MAX(uniqueid), 0) + 1 FROM {quiz_attempts}'); + + $attempt = [ + 'quiz' => $quizid, + 'userid' => $userid, + 'attempt' => $attempt_number, + 'timestart' => time() - 3600, + 'timefinish' => time(), + 'sumgrades' => 10, + 'layout' => '1,0,2,0,3,0,4,0,5,0,6,0', // array of slot numbers on the page in order + 'uniqueid' => $uniqueid, + ]; + + $question_attempt = [ + 'questionusageid' => $uniqueid, // Linked to the test attempt + 'slot' => 1, // Question slot number in the test + 'behaviour' => 'manualgraded', // Essay requires manual grading + 'questionid' => $questionid, // Question ID + 'variant' => 1, // Question variant + 'maxmark' => 10.0, // Maximum score for the question + 'minfraction' => 0.0, // Minimum fraction of the score + 'maxfraction' => 1.0, // Maximum fraction of the score + 'flagged' => 0, // Flag indicating whether the question was flagged + 'questionsummary' => 'Essay question', // Brief description of the question + 'rightanswer' => '', // For essays, there may be no correct answer + 'responsesummary' => '', // Summary response (if any) + 'timemodified' => time(), // Time of modification + ]; + + // Insert a record into the quiz_attempt* tables + try { + $DB->insert_record('quiz_attempts', $attempt); + $question_attempt_id = $DB->insert_record('question_attempts', $question_attempt); + + $question_attempt_step = [ + 'questionattemptid' => $question_attempt_id, // ID of the question from the `mdl_question_attempts` table + 'sequencenumber' => 1, // Step sequence number + 'state' => 'complete', // Step state + 'fraction' => 0.0, // For essays, this may be 0 until graded + 'timecreated' => time(), // Time of step creation + 'userid' => $userid, // ID of the user who created the step + ]; + + $question_attempt_step_id = $DB->insert_record('question_attempt_steps', $question_attempt_step); + + // Example of how to shorten the answer length + $answer_length = strlen($exapmle_answers) - ($student_id * strlen($exapmle_answers) / 6); // Reduce the answer by 10 characters for each subsequent student + $student_answer = substr($exapmle_answers, 0, $answer_length); + + // Saving the answer text + $question_attempt_step_data_answer = [ + 'attemptstepid' => $question_attempt_step_id, // ID of the step from the `mdl_question_attempt_steps` table + 'name' => 'answer', // Data name (e.g., 'answer') + 'value' => $student_answer, // User's answer + ]; + + // Saving the answer format + $question_attempt_step_data_format = [ + 'attemptstepid' => $question_attempt_step_id, // ID of the step from the `mdl_question_attempt_steps` table + 'name' => 'answerformat', // Data name (e.g., 'answerformat') + 'value' => '1', // Answer format (1 = HTML, 0 = plain text, etc.) + ]; + + // Inserting data into the question_attempt_step_data table + $DB->insert_record('question_attempt_step_data', $question_attempt_step_data_answer); + $DB->insert_record('question_attempt_step_data', $question_attempt_step_data_format); + + echo `Quiz attempt inserted successfully. $quizid\n`; + } catch (dml_write_exception $e) { + // Handling database write errors + echo "Error inserting quiz attempt: " . $e->getMessage() . "\n"; + } + } + + protected function setUp(): void + { + global $DB; + + $this->resetAfterTest(); + parent::setUp(); + + $DB->execute('TRUNCATE TABLE {quiz_attempts}'); + $DB->execute('TRUNCATE TABLE {quiz_slots}'); + } +} diff --git a/install_moodle.sh b/install_moodle.sh index 73e182ff52c775b23a5e7510bfd981bcba1f2294..2c439160cb02a86a14da71e74c4718bc2be497a9 100755 --- a/install_moodle.sh +++ b/install_moodle.sh @@ -22,6 +22,7 @@ docker-compose exec moodle chmod -R 775 ${MOODLE_BASE_DIR_DATA} sleep 5 docker-compose exec moodle php ${MOODLE_BASE_DIR}/admin/cli/install.php \ --wwwroot="${MOODLE_WWWROOT}" \ + --phpunit_dataroot="${MOODLE_WWWROOT}" \ --dataroot="${MOODLE_BASE_DIR_DATA}" \ --dbtype="mariadb" \ --dbname="${MOODLE_DATABASE_NAME}" \ @@ -46,6 +47,10 @@ docker-compose exec moodle php ${MOODLE_BASE_DIR}/admin/cli/install.php \ echo "No database backup found. Skipping restore." fi +# Composer installation to run phpunit tests + docker-compose exec moodle composer install +# Next, configure PHPUnit for Moodle: + docker-compose exec moodle php admin/tool/phpunit/cli/init.php # Ensure correct ownership and permissions after installation docker-compose exec moodle chown -R www-data:www-data ${MOODLE_BASE_DIR} diff --git a/readme.md b/readme.md index 7113dec914e70e94fdb40f7a4991386811afd673..7afb3140a028d0507f690e8ed6e3cd154ddb5522 100755 --- a/readme.md +++ b/readme.md @@ -23,4 +23,11 @@ route http://127.0.0.1:5000/api/autograde Now the preinstalled MOODLE LMS is available at https://www.moodle.loc -**Note**: Bind https://www.moodle.loc to your localhost at **hosts** file depending on your OS. \ No newline at end of file +**Note**: Bind https://www.moodle.loc to your localhost at **hosts** file depending on your OS. + +## Running Unit Tests +To run only Plugin's Test please run at project's CLI: +~~~bash +vendor/bin/phpunit --testsuite local_asystgrade_testsuite +~~~ +