<?php
/**
* Plugin Name: IELTS Exam LMS (MVP)
* Description: IELTS-style exam MVP: Tests, Student attempts, Teacher review.
* Version: 0.1.3
* Author: Abdullah
*/
if (!defined(‘ABSPATH’)) exit;
class IELTS_Exam_LMS_MVP {
public function __construct() {
add_action(‘init’, [$this, ‘register_post_types’]);
add_action(‘init’, [$this, ‘maybe_create_tables’]);
add_action(‘admin_menu’, [$this, ‘admin_menu’]);
add_action(‘admin_menu’, [$this, ‘add_grading_queue_menu’]);
add_shortcode(‘ielts_student_portal’, [$this, ‘student_portal_shortcode’]);
add_action(‘admin_post_ielts_create_student’, [$this, ‘handle_create_student’]);
add_action(‘admin_post_nopriv_ielts_student_login’, [$this, ‘handle_student_login’]);
add_action(‘admin_post_ielts_student_logout’, [$this, ‘handle_student_logout’]);
add_action(‘add_meta_boxes’, [$this, ‘add_test_metaboxes’]);
add_action(‘save_post_ielts_test’, [$this, ‘save_test_meta’], 10, 2);
add_action(‘admin_post_ielts_mark_graded’, [$this, ‘handle_mark_graded’]);
}
/* ———————–
* Post Types
* ———————– */
public function register_post_types() {
register_post_type(‘ielts_test’, [
‘label’ => ‘IELTS Tests’,
‘public’ => false,
‘show_ui’ => true,
‘menu_icon’ => ‘dashicons-welcome-learn-more’,
‘supports’ => [‘title’],
]);
register_post_type(‘ielts_student’, [
‘label’ => ‘IELTS Students’,
‘public’ => false,
‘show_ui’ => true,
‘menu_icon’ => ‘dashicons-id’,
‘supports’ => [‘title’],
]);
}
/* ———————–
* DB Table: Attempts
* ———————– */
public function maybe_create_tables() {
$version = get_option(‘ielts_lms_db_version’);
if ($version === ‘1.0’) return;
global $wpdb;
$table = $wpdb->prefix . ‘ielts_attempts’;
$charset = $wpdb->get_charset_collate();
require_once ABSPATH . ‘wp-admin/includes/upgrade.php’;
$sql = “CREATE TABLE $table (
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
student_id VARCHAR(50) NOT NULL,
test_id BIGINT(20) UNSIGNED NOT NULL,
module VARCHAR(20) NOT NULL DEFAULT ‘reading’,
answers LONGTEXT NULL,
score INT(11) NOT NULL DEFAULT 0,
total INT(11) NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT ‘graded’,
teacher_feedback LONGTEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY student_id (student_id),
KEY test_id (test_id),
KEY status (status)
) $charset;”;
dbDelta($sql);
update_option(‘ielts_lms_db_version’, ‘1.0’);
}
/* ———————–
* Admin Menu
* ———————– */
public function admin_menu() {
add_menu_page(
‘IELTS LMS’,
‘IELTS LMS’,
‘manage_options’,
‘ielts-lms’,
[$this, ‘admin_page’],
‘dashicons-clipboard’,
26
);
}
public function admin_page() {
$created = isset($_GET[‘created’]) ? sanitize_text_field($_GET[‘created’]) : ”;
$error = isset($_GET[‘err’]) ? sanitize_text_field($_GET[‘err’]) : ”;
echo ‘<div class=”wrap”><h1>IELTS LMS (MVP)</h1>’;
if ($created) {
echo ‘<div class=”notice notice-success”><p>Student created: <strong>’ . esc_html($created) . ‘</strong></p></div>’;
}
if ($error) {
echo ‘<div class=”notice notice-error”><p>’ . esc_html($error) . ‘</p></div>’;
}
echo ‘<h2>Create Student (ID + PIN)</h2>’;
echo ‘<form method=”post” action=”‘ . esc_url(admin_url(‘admin-post.php’)) . ‘”>’;
echo ‘<input type=”hidden” name=”action” value=”ielts_create_student”>’;
wp_nonce_field(‘ielts_create_student’);
echo ‘<table class=”form-table” role=”presentation”>’;
echo ‘<tr><th><label>Student ID</label></th><td><input name=”student_id” required placeholder=”STJ-10021″ class=”regular-text”></td></tr>’;
echo ‘<tr><th><label>PIN (4-8 digits)</label></th><td><input name=”pin” required placeholder=”1234″ class=”regular-text”></td></tr>’;
echo ‘</table>’;
submit_button(‘Create Student’);
echo ‘</form>’;
echo ‘<hr />’;
echo ‘<p>Student Portal Shortcode: <code>
Student Portal
Student ID + PIN দিয়ে লগইন করুন:
echo ‘</div>’;
}
/* ———————–
* Student Portal Shortcode
* ———————– */
public function student_portal_shortcode() {
$student_id = isset($_COOKIE[‘ielts_student_id’]) ? sanitize_text_field($_COOKIE[‘ielts_student_id’]) : ”;
$base_url = remove_query_arg([‘test_id’, ‘login_err’]);
ob_start();
?>
<div style=”max-width:820px;padding:16px;border:1px solid #ddd;border-radius:10px;”>
<h3>Student Portal</h3>
<?php if ($student_id): ?>
<p>✅ Logged in as: <strong><?php echo esc_html($student_id); ?></strong></p>
<form method=”post” action=”<?php echo esc_url(admin_url(‘admin-post.php’)); ?>” style=”margin:8px 0 14px;”>
<input type=”hidden” name=”action” value=”ielts_student_logout”>
<?php wp_nonce_field(‘ielts_student_logout’); ?>
<button type=”submit” style=”padding:10px 14px;border-radius:8px;border:1px solid #ccc;cursor:pointer;”>Logout</button>
</form>
<?php echo $this->render_student_results($student_id); ?>
<hr />
<?php
$exam_test_id = isset($_GET[‘test_id’]) ? intval($_GET[‘test_id’]) : 0;
if ($exam_test_id) {
echo $this->render_exam_screen($exam_test_id, $base_url);
} else {
echo $this->render_available_tests($base_url);
}
?>
<?php else: ?>
<p>Student ID + PIN দিয়ে লগইন করুন:</p>
<?php if (isset($_GET[‘login_err’])): ?>
<p style=”color:#b00020;”>❌ Invalid Student ID or PIN</p>
<?php endif; ?>
<form method=”post” action=”<?php echo esc_url(admin_url(‘admin-post.php’)); ?>”>
<input type=”hidden” name=”action” value=”ielts_student_login”>
<?php wp_nonce_field(‘ielts_student_login’); ?>
<div style=”display:flex;gap:10px;flex-wrap:wrap;”>
<input name=”student_id” required placeholder=”Student ID (e.g. STJ-10021)” style=”flex:1;min-width:240px;padding:10px;border:1px solid #ccc;border-radius:8px;”>
<input name=”pin” required placeholder=”PIN” style=”width:160px;padding:10px;border:1px solid #ccc;border-radius:8px;”>
</div>
<div style=”margin-top:12px;”>
<button type=”submit” style=”padding:10px 14px;border-radius:8px;border:1px solid #0b74de;background:#0b74de;color:#fff;cursor:pointer;”>
Login
</button>
</div>
</form>
<?php endif; ?>
</div>
<?php
return ob_get_clean();
}
private function render_student_results($student_id) {
global $wpdb;
$table = $wpdb->prefix . ‘ielts_attempts’;
$rows = $wpdb->get_results($wpdb->prepare(
“SELECT * FROM $table WHERE student_id=%s ORDER BY id DESC LIMIT 20″,
$student_id
));
if (!$rows) return ‘<p><strong>My Results:</strong> No attempts yet.</p>’;
ob_start();
echo ‘<h4>My Results</h4>’;
echo ‘<div style=”overflow:auto;border:1px solid #eee;border-radius:10px;”>’;
echo ‘<table style=”width:100%;border-collapse:collapse;”>’;
echo ‘<tr style=”background:#fafafa;”>
<th style=”text-align:left;padding:10px;border-bottom:1px solid #eee;”>Date</th>
<th style=”text-align:left;padding:10px;border-bottom:1px solid #eee;”>Module</th>
<th style=”text-align:left;padding:10px;border-bottom:1px solid #eee;”>Score/Band</th>
<th style=”text-align:left;padding:10px;border-bottom:1px solid #eee;”>Status</th>
</tr>’;
foreach ($rows as $r) {
$band = ‘-‘;
if (!empty($r->teacher_feedback)) {
$tf = json_decode($r->teacher_feedback, true);
if (is_array($tf)) $band = $tf[‘band’] ?? ‘-‘;
}
$score_or_band = ($r->module === ‘reading’ || $r->module === ‘listening’)
? ($r->score . ‘/’ . $r->total)
: $band;
echo ‘<tr>’;
echo ‘<td style=”padding:10px;border-bottom:1px solid #f0f0f0;”>’ . esc_html($r->created_at) . ‘</td>’;
echo ‘<td style=”padding:10px;border-bottom:1px solid #f0f0f0;”>’ . esc_html(strtoupper($r->module)) . ‘</td>’;
echo ‘<td style=”padding:10px;border-bottom:1px solid #f0f0f0;”>’ . esc_html($score_or_band) . ‘</td>’;
echo ‘<td style=”padding:10px;border-bottom:1px solid #f0f0f0;”>’ . esc_html(strtoupper($r->status)) . ‘</td>’;
echo ‘</tr>’;
}
echo ‘</table></div>’;
return ob_get_clean();
}
/* ———————–
* Student Create/Login/Logout
* ———————– */
public function handle_create_student() {
if (!current_user_can(‘manage_options’)) wp_die(‘Unauthorized’);
check_admin_referer(‘ielts_create_student’);
$student_id = isset($_POST[‘student_id’]) ? sanitize_text_field($_POST[‘student_id’]) : ”;
$pin = isset($_POST[‘pin’]) ? sanitize_text_field($_POST[‘pin’]) : ”;
if (!$student_id || !$pin) {
wp_redirect(admin_url(‘admin.php?page=ielts-lms&err=’ . urlencode(‘Student ID and PIN are required.’)));
exit;
}
if (!preg_match(‘/^[A-Za-z0-9\-]{4,30}$/’, $student_id)) {
wp_redirect(admin_url(‘admin.php?page=ielts-lms&err=’ . urlencode(‘Invalid Student ID format. Use letters/numbers/dash only.’)));
exit;
}
if (!preg_match(‘/^[0-9]{4,8}$/’, $pin)) {
wp_redirect(admin_url(‘admin.php?page=ielts-lms&err=’ . urlencode(‘PIN must be 4 to 8 digits.’)));
exit;
}
$existing = get_page_by_title($student_id, OBJECT, ‘ielts_student’);
if ($existing) {
wp_redirect(admin_url(‘admin.php?page=ielts-lms&err=’ . urlencode(‘Student ID already exists.’)));
exit;
}
$post_id = wp_insert_post([
‘post_type’ => ‘ielts_student’,
‘post_title’ => $student_id,
‘post_status’ => ‘publish’,
], true);
if (is_wp_error($post_id)) {
wp_redirect(admin_url(‘admin.php?page=ielts-lms&err=’ . urlencode(‘Failed to create student.’)));
exit;
}
update_post_meta($post_id, ‘_ielts_pin_hash’, wp_hash_password($pin));
wp_redirect(admin_url(‘admin.php?page=ielts-lms&created=’ . urlencode($student_id)));
exit;
}
public function handle_student_login() {
check_admin_referer(‘ielts_student_login’);
$student_id = isset($_POST[‘student_id’]) ? sanitize_text_field($_POST[‘student_id’]) : ”;
$pin = isset($_POST[‘pin’]) ? sanitize_text_field($_POST[‘pin’]) : ”;
$redirect_to = wp_get_referer() ? wp_get_referer() : home_url(‘/’);
if (!$student_id || !$pin) {
wp_redirect(add_query_arg(‘login_err’, ‘1’, $redirect_to));
exit;
}
$student = get_page_by_title($student_id, OBJECT, ‘ielts_student’);
if (!$student) {
wp_redirect(add_query_arg(‘login_err’, ‘1’, $redirect_to));
exit;
}
$hash = get_post_meta($student->ID, ‘_ielts_pin_hash’, true);
if (!$hash || !wp_check_password($pin, $hash)) {
wp_redirect(add_query_arg(‘login_err’, ‘1’, $redirect_to));
exit;
}
setcookie(‘ielts_student_id’, $student_id, time() + (7 * DAY_IN_SECONDS), COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true);
wp_redirect(remove_query_arg(‘login_err’, $redirect_to));
exit;
}
public function handle_student_logout() {
check_admin_referer(‘ielts_student_logout’);
$redirect_to = wp_get_referer() ? wp_get_referer() : home_url(‘/’);
setcookie(‘ielts_student_id’, ”, time() – 3600, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true);
wp_redirect($redirect_to);
exit;
}
/* ———————–
* Test Builder Meta Box
* ———————– */
public function add_test_metaboxes() {
add_meta_box(
‘ielts_test_builder’,
‘IELTS Test Builder (MVP)’,
[$this, ‘render_test_builder_metabox’],
‘ielts_test’,
‘normal’,
‘high’
);
}
public function render_test_builder_metabox($post) {
wp_nonce_field(‘ielts_test_meta_save’, ‘ielts_test_meta_nonce’);
$module = get_post_meta($post->ID, ‘_ielts_module’, true);
if (!$module) $module = ‘reading’;
$time = (int) get_post_meta($post->ID, ‘_ielts_time_limit’, true);
if (!$time) $time = ($module === ‘writing’) ? 60 : 40;
$listening_audio = get_post_meta($post->ID, ‘_ielts_listening_audio’, true);
$writing_prompt = get_post_meta($post->ID, ‘_ielts_writing_prompt’, true);
$passage = get_post_meta($post->ID, ‘_ielts_passage’, true);
$questions_json = get_post_meta($post->ID, ‘_ielts_questions_json’, true);
if (!$questions_json) {
$questions_json = json_encode([
[“id”=>1,”type”=>”mcq”,”q”=>”What is the main topic of the passage?”,”options”=>[“Tea”,”Industrial Revolution”,”Japan”,”Population”],”answer”=>”Industrial Revolution”],
[“id”=>2,”type”=>”tfng”,”q”=>”Macfarlane is a professor at King’s College, Cambridge.”,”answer”=>”TRUE”],
[“id”=>3,”type”=>”gap”,”q”=>”Tea and beer helped reduce ______ diseases.”,”answer”=>”water-borne”],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
}
echo ‘<p><strong>Module</strong></p>’;
echo ‘<select name=”ielts_module” style=”min-width:220px;”>’;
foreach ([‘reading’=>’Reading’,’listening’=>’Listening’,’writing’=>’Writing’] as $k=>$label) {
echo ‘<option value=”‘.esc_attr($k).’” ‘.selected($module,$k,false).’>’.esc_html($label).'</option>’;
}
echo ‘</select><hr />’;
echo ‘<p><strong>Time Limit (minutes)</strong></p>’;
echo ‘<input name=”ielts_time_limit” type=”number” value=”‘.esc_attr($time).’” style=”width:120px;” />’;
echo ‘<hr />’;
echo ‘<p><strong>Listening Audio URL (for Listening module)</strong></p>’;
echo ‘<input name=”ielts_listening_audio” type=”text” value=”‘.esc_attr($listening_audio).’” style=”width:100%;max-width:720px;” placeholder=”https://…/audio.mp3″ />’;
echo ‘<p style=”color:#555;margin-top:6px;”>MP3 direct link দিন। WordPress Media Library তে আপলোড করে URL নিলেও হবে।</p>’;
echo ‘<hr />’;
echo ‘<p><strong>Writing Prompt (for Writing module)</strong></p>’;
echo ‘<textarea name=”ielts_writing_prompt” style=”width:100%;min-height:140px;”>’ . esc_textarea($writing_prompt) . ‘</textarea>’;
echo ‘<p style=”color:#555;”>Reading/Listening হলে Passage+Questions ব্যবহার হবে, Writing হলে Prompt + Essay submission হবে।</p>’;
echo ‘<hr />’;
echo ‘<p><strong>Passage (Reading Text)</strong></p>’;
echo ‘<textarea name=”ielts_passage” style=”width:100%;min-height:180px;”>’ . esc_textarea($passage) . ‘</textarea>’;
echo ‘<hr />’;
echo ‘<p><strong>Questions (JSON)</strong> — types: <code>mcq</code>, <code>tfng</code>, <code>gap</code></p>’;
echo ‘<textarea name=”ielts_questions_json” style=”width:100%;min-height:260px;font-family:monospace;”>’ . esc_textarea($questions_json) . ‘</textarea>’;
echo ‘<p style=”margin-top:8px;color:#555;”>Answer field is used for auto-scoring (Reading/Listening).</p>’;
}
public function save_test_meta($post_id, $post) {
if (!isset($_POST[‘ielts_test_meta_nonce’]) || !wp_verify_nonce($_POST[‘ielts_test_meta_nonce’], ‘ielts_test_meta_save’)) return;
if (defined(‘DOING_AUTOSAVE’) && DOING_AUTOSAVE) return;
if (!current_user_can(‘edit_post’, $post_id)) return;
$module = isset($_POST[‘ielts_module’]) ? sanitize_text_field($_POST[‘ielts_module’]) : ‘reading’;
$writing_prompt = isset($_POST[‘ielts_writing_prompt’]) ? wp_kses_post($_POST[‘ielts_writing_prompt’]) : ”;
$time_default = ($module === ‘writing’) ? 60 : 40;
$time = isset($_POST[‘ielts_time_limit’]) ? intval($_POST[‘ielts_time_limit’]) : $time_default;
if ($time < 1) $time = $time_default;
$listening_audio = isset($_POST[‘ielts_listening_audio’]) ? esc_url_raw($_POST[‘ielts_listening_audio’]) : ”;
$passage = isset($_POST[‘ielts_passage’]) ? wp_kses_post($_POST[‘ielts_passage’]) : ”;
$questions_json = isset($_POST[‘ielts_questions_json’]) ? wp_unslash($_POST[‘ielts_questions_json’]) : ”;
if ($questions_json) {
json_decode($questions_json, true);
if (json_last_error() !== JSON_ERROR_NONE) return;
}
update_post_meta($post_id, ‘_ielts_module’, $module);
update_post_meta($post_id, ‘_ielts_writing_prompt’, $writing_prompt);
update_post_meta($post_id, ‘_ielts_time_limit’, $time);
update_post_meta($post_id, ‘_ielts_listening_audio’, $listening_audio);
update_post_meta($post_id, ‘_ielts_passage’, $passage);
update_post_meta($post_id, ‘_ielts_questions_json’, wp_slash($questions_json));
}
/* ———————–
* Student Views: Tests + Exam
* ———————– */
public function render_available_tests($base_url) {
$tests = get_posts([
‘post_type’ => ‘ielts_test’,
‘post_status’ => ‘publish’,
‘numberposts’ => 50
]);
ob_start();
echo ‘<h4>Available Tests</h4>’;
if (!$tests) {
echo ‘<p>No tests found. Admin needs to create tests first.</p>’;
return ob_get_clean();
}
echo ‘<ul style=”padding-left:18px;”>’;
foreach ($tests as $t) {
$module = get_post_meta($t->ID, ‘_ielts_module’, true);
if (!$module) $module = ‘reading’;
$url = add_query_arg(‘test_id’, $t->ID, $base_url);
echo ‘<li style=”margin:10px 0;”>’;
echo ‘<strong>’ . esc_html($t->post_title) . ‘</strong> ‘;
echo ‘<span style=”color:#666;”>(‘ . esc_html(strtoupper($module)) . ‘)</span> ‘;
echo ‘ – <a href=”‘ . esc_url($url) . ‘”>Start Exam</a>’;
echo ‘</li>’;
}
echo ‘</ul>’;
return ob_get_clean();
}
public function render_exam_screen($test_id, $base_url) {
$post = get_post($test_id);
if (!$post || $post->post_type !== ‘ielts_test’) return ‘<p>Invalid test.</p>’;
$module = get_post_meta($test_id, ‘_ielts_module’, true);
if (!$module) $module = ‘reading’;
if ($module === ‘listening’) return $this->render_listening_screen($test_id, $base_url);
if ($module === ‘writing’) return $this->render_writing_screen($test_id, $base_url);
return $this->render_reading_screen($test_id, $base_url);
}
private function render_reading_screen($test_id, $base_url) {
$time = (int) get_post_meta($test_id, ‘_ielts_time_limit’, true);
if (!$time) $time = 40;
$passage = get_post_meta($test_id, ‘_ielts_passage’, true);
$questions = json_decode(get_post_meta($test_id, ‘_ielts_questions_json’, true), true);
if (!$questions || !is_array($questions)) return ‘<p>This test has no valid questions.</p>’;
// submit
if ($_SERVER[‘REQUEST_METHOD’] === ‘POST’
&& isset($_POST[‘ielts_submit_exam’])
&& isset($_POST[‘ielts_exam_nonce’])
&& wp_verify_nonce($_POST[‘ielts_exam_nonce’], ‘ielts_submit_exam’)) {
$score = 0;
$total = count($questions);
foreach ($questions as $q) {
$qid = intval($q[‘id’] ?? 0);
$correct = trim((string)($q[‘answer’] ?? ”));
$given = isset($_POST[‘ans’][$qid]) ? trim((string)sanitize_text_field($_POST[‘ans’][$qid])) : ”;
if (strcasecmp($given, $correct) === 0) $score++;
}
global $wpdb;
$table = $wpdb->prefix . ‘ielts_attempts’;
$student_id = isset($_COOKIE[‘ielts_student_id’]) ? sanitize_text_field($_COOKIE[‘ielts_student_id’]) : ‘unknown’;
$wpdb->insert($table, [
‘student_id’ => $student_id,
‘test_id’ => intval($test_id),
‘module’ => ‘reading’,
‘answers’ => wp_json_encode($_POST[‘ans’]),
‘score’ => intval($score),
‘total’ => intval($total),
‘status’ => ‘graded’,
‘teacher_feedback’ => null,
]);
$back_url = remove_query_arg(‘test_id’, $base_url);
return ‘<div style=”padding:12px;border:1px solid #ddd;border-radius:10px;”>
<h4>✅ Submitted!</h4>
<p><strong>Score:</strong> ‘.esc_html($score).’ / ‘.esc_html($total).'</p>
<p><a href=”‘.esc_url($back_url).’”>← Back to tests</a></p>
</div>’;
}
ob_start(); ?>
<div style=”display:flex;gap:16px;flex-wrap:wrap;”>
<div style=”flex:1;min-width:300px;border:1px solid #ddd;border-radius:10px;padding:12px;max-height:520px;overflow:auto;”>
<h4>Reading Passage</h4>
<div style=”white-space:pre-wrap;line-height:1.6;”><?php echo wp_kses_post($passage); ?></div>
</div>
<div style=”flex:1;min-width:300px;border:1px solid #ddd;border-radius:10px;padding:12px;”>
<h4>Questions</h4>
<div id=”ielts-timer” style=”font-size:18px;font-weight:700;margin:10px 0;”>
Time left: <span id=”time”><?php echo esc_html($time); ?>:00</span>
</div>
<form method=”post” id=”ielts-exam-form”>
<?php wp_nonce_field(‘ielts_submit_exam’, ‘ielts_exam_nonce’); ?>
<input type=”hidden” name=”ielts_submit_exam” value=”1″ />
<?php foreach ($questions as $q):
$qid = intval($q[‘id’]);
$type = $q[‘type’] ?? ‘gap’;
$text = $q[‘q’] ?? ”;
?>
<div style=”margin:12px 0;padding:10px;border:1px solid #eee;border-radius:10px;”>
<div style=”font-weight:600;margin-bottom:6px;”><?php echo esc_html($qid . ‘. ‘ . $text); ?></div>
<?php if ($type === ‘mcq’ && !empty($q[‘options’]) && is_array($q[‘options’])): ?>
<?php foreach ($q[‘options’] as $opt): ?>
<label style=”display:block;margin:6px 0;”>
<input type=”radio” name=”ans[<?php echo esc_attr($qid); ?>]” value=”<?php echo esc_attr($opt); ?>” required>
<?php echo esc_html($opt); ?>
</label>
<?php endforeach; ?>
<?php elseif ($type === ‘tfng’): ?>
<?php foreach ([‘TRUE’,’FALSE’,’NOT GIVEN’] as $opt): ?>
<label style=”display:inline-block;margin-right:12px;”>
<input type=”radio” name=”ans[<?php echo esc_attr($qid); ?>]” value=”<?php echo esc_attr($opt); ?>” required>
<?php echo esc_html($opt); ?>
</label>
<?php endforeach; ?>
<?php else: ?>
<input type=”text” name=”ans[<?php echo esc_attr($qid); ?>]” placeholder=”Type your answer” style=”width:100%;padding:10px;border:1px solid #ccc;border-radius:8px;” required>
<?php endif; ?>
</div>
<?php endforeach; ?>
<button type=”submit” style=”padding:10px 14px;border-radius:8px;border:1px solid #0b74de;background:#0b74de;color:#fff;cursor:pointer;”>Submit</button>
</form>
<script>
(function(){
var total = <?php echo (int)$time; ?> * 60;
var el = document.getElementById(‘time’);
var form = document.getElementById(‘ielts-exam-form’);
var t = setInterval(function(){
var m = Math.floor(total/60);
var s = total % 60;
el.textContent = m + ‘:’ + (s < 10 ? ‘0’+s : s);
if (total <= 0) { clearInterval(t); form.submit(); }
total–;
}, 1000);
})();
</script>
<p style=”margin-top:10px;”><a href=”<?php echo esc_url(remove_query_arg(‘test_id’, $base_url)); ?>”>← Cancel</a></p>
</div>
</div>
<?php
return ob_get_clean();
}
public function render_listening_screen($test_id, $base_url) {
$time = (int) get_post_meta($test_id, ‘_ielts_time_limit’, true);
if (!$time) $time = 40;
$audio = get_post_meta($test_id, ‘_ielts_listening_audio’, true);
$questions = json_decode(get_post_meta($test_id, ‘_ielts_questions_json’, true), true);
if (!$questions || !is_array($questions)) return ‘<p>This test has no valid questions.</p>’;
// submit
if ($_SERVER[‘REQUEST_METHOD’] === ‘POST’
&& isset($_POST[‘ielts_submit_listening’])
&& isset($_POST[‘ielts_listening_nonce’])
&& wp_verify_nonce($_POST[‘ielts_listening_nonce’], ‘ielts_submit_listening’)) {
$score = 0;
$total = count($questions);
foreach ($questions as $q) {
$qid = intval($q[‘id’] ?? 0);
$correct = trim((string)($q[‘answer’] ?? ”));
$given = isset($_POST[‘ans’][$qid]) ? trim((string)sanitize_text_field($_POST[‘ans’][$qid])) : ”;
if (strcasecmp($given, $correct) === 0) $score++;
}
global $wpdb;
$table = $wpdb->prefix . ‘ielts_attempts’;
$student_id = isset($_COOKIE[‘ielts_student_id’]) ? sanitize_text_field($_COOKIE[‘ielts_student_id’]) : ‘unknown’;
$wpdb->insert($table, [
‘student_id’ => $student_id,
‘test_id’ => intval($test_id),
‘module’ => ‘listening’,
‘answers’ => wp_json_encode($_POST[‘ans’]),
‘score’ => intval($score),
‘total’ => intval($total),
‘status’ => ‘graded’,
‘teacher_feedback’ => null,
]);
$back_url = remove_query_arg(‘test_id’, $base_url);
return ‘<div style=”padding:12px;border:1px solid #ddd;border-radius:10px;”>
<h4>✅ Submitted!</h4>
<p><strong>Score:</strong> ‘.esc_html($score).’ / ‘.esc_html($total).'</p>
<p><a href=”‘.esc_url($back_url).’”>← Back to tests</a></p>
</div>’;
}
ob_start(); ?>
<div style=”border:1px solid #ddd;border-radius:10px;padding:12px;”>
<h4>Listening Test</h4>
<div id=”ielts-timer” style=”font-size:18px;font-weight:700;margin:10px 0;”>
Time left: <span id=”time”><?php echo esc_html($time); ?>:00</span>
</div>
<?php if ($audio): ?>
<audio controls style=”width:100%;margin:8px 0;”>
<source src=”<?php echo esc_url($audio); ?>” type=”audio/mpeg”>
</audio>
<?php else: ?>
<p style=”color:#b00020;”>⚠️ Audio URL not set. Admin needs to add MP3 URL in test builder.</p>
<?php endif; ?>
<form method=”post” id=”ielts-listening-form”>
<?php wp_nonce_field(‘ielts_submit_listening’, ‘ielts_listening_nonce’); ?>
<input type=”hidden” name=”ielts_submit_listening” value=”1″ />
<?php foreach ($questions as $q):
$qid = intval($q[‘id’]);
$type = $q[‘type’] ?? ‘gap’;
$text = $q[‘q’] ?? ”;
?>
<div style=”margin:12px 0;padding:10px;border:1px solid #eee;border-radius:10px;”>
<div style=”font-weight:600;margin-bottom:6px;”><?php echo esc_html($qid . ‘. ‘ . $text); ?></div>
<?php if ($type === ‘mcq’ && !empty($q[‘options’]) && is_array($q[‘options’])): ?>
<?php foreach ($q[‘options’] as $opt): ?>
<label style=”display:block;margin:6px 0;”>
<input type=”radio” name=”ans[<?php echo esc_attr($qid); ?>]” value=”<?php echo esc_attr($opt); ?>” required>
<?php echo esc_html($opt); ?>
</label>
<?php endforeach; ?>
<?php elseif ($type === ‘tfng’): ?>
<?php foreach ([‘TRUE’,’FALSE’,’NOT GIVEN’] as $opt): ?>
<label style=”display:inline-block;margin-right:12px;”>
<input type=”radio” name=”ans[<?php echo esc_attr($qid); ?>]” value=”<?php echo esc_attr($opt); ?>” required>
<?php echo esc_html($opt); ?>
</label>
<?php endforeach; ?>
<?php else: ?>
<input type=”text” name=”ans[<?php echo esc_attr($qid); ?>]” placeholder=”Type your answer” style=”width:100%;padding:10px;border:1px solid #ccc;border-radius:8px;” required>
<?php endif; ?>
</div>
<?php endforeach; ?>
<button type=”submit” style=”padding:10px 14px;border-radius:8px;border:1px solid #0b74de;background:#0b74de;color:#fff;cursor:pointer;”>Submit</button>
</form>
<script>
(function(){
var total = <?php echo (int)$time; ?> * 60;
var el = document.getElementById(‘time’);
var form = document.getElementById(‘ielts-listening-form’);
var t = setInterval(function(){
var m = Math.floor(total/60);
var s = total % 60;
el.textContent = m + ‘:’ + (s < 10 ? ‘0’+s : s);
if (total <= 0) { clearInterval(t); form.submit(); }
total–;
}, 1000);
})();
</script>
<p style=”margin-top:10px;”><a href=”<?php echo esc_url(remove_query_arg(‘test_id’, $base_url)); ?>”>← Cancel</a></p>
</div>
<?php
return ob_get_clean();
}
public function render_writing_screen($test_id, $base_url) {
$time = (int) get_post_meta($test_id, ‘_ielts_time_limit’, true);
if (!$time) $time = 60;
$prompt = get_post_meta($test_id, ‘_ielts_writing_prompt’, true);
$student_id = isset($_COOKIE[‘ielts_student_id’]) ? sanitize_text_field($_COOKIE[‘ielts_student_id’]) : ”;
if ($_SERVER[‘REQUEST_METHOD’] === ‘POST’
&& isset($_POST[‘ielts_submit_writing’])
&& isset($_POST[‘ielts_writing_nonce’])
&& wp_verify_nonce($_POST[‘ielts_writing_nonce’], ‘ielts_submit_writing’)) {
$essay = isset($_POST[‘essay’]) ? wp_kses_post($_POST[‘essay’]) : ”;
$word_count = str_word_count(wp_strip_all_tags($essay));
global $wpdb;
$table = $wpdb->prefix . ‘ielts_attempts’;
$wpdb->insert($table, [
‘student_id’ => $student_id,
‘test_id’ => $test_id,
‘module’ => ‘writing’,
‘answers’ => wp_json_encode([‘essay’ => $essay, ‘words’ => $word_count]),
‘score’ => 0,
‘total’ => 0,
‘status’ => ‘pending’,
‘teacher_feedback’ => null,
]);
$back_url = remove_query_arg(‘test_id’, $base_url);
return ‘<div style=”padding:12px;border:1px solid #ddd;border-radius:10px;”>
<h4>✅ Submitted!</h4>
<p>Your writing has been submitted for teacher review.</p>
<p><a href=”‘ . esc_url($back_url) . ‘”>← Back to tests</a></p>
</div>’;
}
ob_start(); ?>
<div style=”border:1px solid #ddd;border-radius:10px;padding:12px;”>
<h4>Writing Task</h4>
<div id=”ielts-timer” style=”font-size:18px;font-weight:700;margin:10px 0;”>
Time left: <span id=”time”><?php echo esc_html($time); ?>:00</span>
</div>
<div style=”background:#fff;border:1px solid #eee;border-radius:10px;padding:12px;white-space:pre-wrap;line-height:1.6;”>
<?php echo wp_kses_post($prompt); ?>
</div>
<form method=”post” style=”margin-top:12px;” id=”ielts-writing-form”>
<?php wp_nonce_field(‘ielts_submit_writing’, ‘ielts_writing_nonce’); ?>
<input type=”hidden” name=”ielts_submit_writing” value=”1″ />
<p style=”margin:10px 0 6px;font-weight:600;”>Write your answer:</p>
<textarea name=”essay” required style=”width:100%;min-height:260px;padding:10px;border:1px solid #ccc;border-radius:10px;”></textarea>
<p style=”margin:8px 0 0;”>Words: <strong><span id=”wc”>0</span></strong></p>
<button type=”submit” style=”margin-top:10px;padding:10px 14px;border-radius:8px;border:1px solid #0b74de;background:#0b74de;color:#fff;cursor:pointer;”>Submit Writing</button>
</form>
<script>
(function(){
// timer
var total = <?php echo (int)$time; ?> * 60;
var el = document.getElementById(‘time’);
var form = document.getElementById(‘ielts-writing-form’);
var t = setInterval(function(){
var m = Math.floor(total/60);
var s = total % 60;
el.textContent = m + ‘:’ + (s < 10 ? ‘0’+s : s);
if (total <= 0) { clearInterval(t); form.submit(); }
total–;
}, 1000);
// word count
var ta = document.querySelector(‘textarea[name=essay]’);
var wc = document.getElementById(‘wc’);
function countWords(v){
v = (v || ”).trim();
if(!v) return 0;
return v.split(/\s+/).filter(Boolean).length;
}
ta.addEventListener(‘input’, function(){
wc.textContent = countWords(this.value);
});
})();
</script>
<p style=”margin-top:10px;”><a href=”<?php echo esc_url(remove_query_arg(‘test_id’, $base_url)); ?>”>← Cancel</a></p>
</div>
<?php
return ob_get_clean();
}
/* ———————–
* Grading Queue (Admin)
* ———————– */
public function add_grading_queue_menu() {
add_submenu_page(
‘ielts-lms’,
‘Grading Queue’,
‘Grading Queue’,
‘manage_options’,
‘ielts-grading-queue’,
[$this, ‘render_grading_queue_page’]
);
}
public function render_grading_queue_page() {
global $wpdb;
$table = $wpdb->prefix . ‘ielts_attempts’;
$search = isset($_GET[‘s’]) ? sanitize_text_field($_GET[‘s’]) : ”;
$module = isset($_GET[‘module’]) ? sanitize_text_field($_GET[‘module’]) : ”;
$status = isset($_GET[‘status’]) ? sanitize_text_field($_GET[‘status’]) : ”;
$pending = (int) $wpdb->get_var(“SELECT COUNT(*) FROM $table WHERE status=’pending’”);
$graded = (int) $wpdb->get_var(“SELECT COUNT(*) FROM $table WHERE status=’graded’”);
$reading = (int) $wpdb->get_var(“SELECT COUNT(*) FROM $table WHERE module=’reading’”);
$listening = (int) $wpdb->get_var(“SELECT COUNT(*) FROM $table WHERE module=’listening’”);
$writing = (int) $wpdb->get_var(“SELECT COUNT(*) FROM $table WHERE module=’writing’”);
$speaking = (int) $wpdb->get_var(“SELECT COUNT(*) FROM $table WHERE module=’speaking’”);
$where = “WHERE 1=1 “;
$params = [];
if ($module) { $where .= ” AND module=%s “; $params[] = $module; }
if ($status) { $where .= ” AND status=%s “; $params[] = $status; }
if ($search) {
$where .= ” AND (student_id LIKE %s OR test_id IN (
SELECT ID FROM {$wpdb->posts} WHERE post_type=’ielts_test’ AND post_title LIKE %s
)) “;
$like = ‘%’ . $wpdb->esc_like($search) . ‘%’;
$params[] = $like;
$params[] = $like;
}
$sql = “SELECT * FROM $table $where ORDER BY id DESC LIMIT 300″;
$rows = $params ? $wpdb->get_results($wpdb->prepare($sql, $params)) : $wpdb->get_results($sql);
echo ‘<div class=”wrap”><h1>Grading Queue</h1>’;
echo ‘<style>
.ielts-cards{display:flex;gap:12px;flex-wrap:wrap;margin:12px 0 16px;}
.ielts-card{background:#fff;border:1px solid #e5e5e5;border-left:6px solid #2271b1;border-radius:10px;padding:12px 14px;min-width:160px}
.ielts-card .label{font-size:12px;color:#666;margin-bottom:6px;text-transform:uppercase;letter-spacing:.02em}
.ielts-card .val{font-size:22px;font-weight:700}
.ielts-card.pending{border-left-color:#d63638}
.ielts-card.graded{border-left-color:#00a32a}
.ielts-filterbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center;background:#fff;border:1px solid #e5e5e5;border-radius:10px;padding:10px 12px;margin:0 0 12px;}
.ielts-filterbar input,.ielts-filterbar select{padding:6px 8px;min-height:34px}
</style>’;
echo ‘<div class=”ielts-cards”>’;
echo ‘<div class=”ielts-card pending”><div class=”label”>Pending Review</div><div class=”val”>’ . esc_html($pending) . ‘</div></div>’;
echo ‘<div class=”ielts-card graded”><div class=”label”>Total Graded</div><div class=”val”>’ . esc_html($graded) . ‘</div></div>’;
echo ‘<div class=”ielts-card”><div class=”label”>Reading</div><div class=”val”>’ . esc_html($reading) . ‘</div></div>’;
echo ‘<div class=”ielts-card”><div class=”label”>Listening</div><div class=”val”>’ . esc_html($listening) . ‘</div></div>’;
echo ‘<div class=”ielts-card”><div class=”label”>Writing</div><div class=”val”>’ . esc_html($writing) . ‘</div></div>’;
echo ‘<div class=”ielts-card”><div class=”label”>Speaking</div><div class=”val”>’ . esc_html($speaking) . ‘</div></div>’;
echo ‘</div>’;
echo ‘<form method=”get” class=”ielts-filterbar”>’;
echo ‘<input type=”hidden” name=”page” value=”ielts-grading-queue” />’;
echo ‘<input type=”text” name=”s” placeholder=”Search student or test…” value=”‘ . esc_attr($search) . ‘” />’;
echo ‘<select name=”module”>’;
echo ‘<option value=””>All Modules</option>’;
foreach ([‘reading’=>’Reading’,’listening’=>’Listening’,’writing’=>’Writing’,’speaking’=>’Speaking’] as $k=>$label) {
echo ‘<option value=”‘ . esc_attr($k) . ‘” ‘ . selected($module, $k, false) . ‘>’ . esc_html($label) . ‘</option>’;
}
echo ‘</select>’;
echo ‘<select name=”status”>’;
echo ‘<option value=””>All Status</option>’;
foreach ([‘pending’=>’Pending’,’graded’=>’Graded’] as $k=>$label) {
echo ‘<option value=”‘ . esc_attr($k) . ‘” ‘ . selected($status, $k, false) . ‘>’ . esc_html($label) . ‘</option>’;
}
echo ‘</select>’;
echo ‘<button class=”button button-primary” type=”submit”>Filter</button>’;
echo ‘<a class=”button” href=”‘ . esc_url(admin_url(‘admin.php?page=ielts-grading-queue’)) . ‘”>Reset</a>’;
echo ‘</form>’;
if (!$rows) { echo ‘<p>No attempts found.</p></div>’; return; }
echo ‘<table class=”widefat fixed striped”>’;
echo ‘<thead><tr>
<th style=”width:70px;”>ID</th>
<th style=”width:160px;”>Date</th>
<th style=”width:140px;”>Student</th>
<th>Test</th>
<th style=”width:90px;”>Module</th>
<th style=”width:90px;”>Score</th>
<th style=”width:90px;”>Status</th>
<th style=”width:90px;”>Action</th>
</tr></thead><tbody>’;
foreach ($rows as $r) {
$test_title = get_the_title((int)$r->test_id);
$view_url = admin_url(‘admin.php?page=ielts-grading-queue&view=’ . (int)$r->id);
echo ‘<tr>’;
echo ‘<td>’ . esc_html($r->id) . ‘</td>’;
echo ‘<td>’ . esc_html($r->created_at) . ‘</td>’;
echo ‘<td>’ . esc_html($r->student_id) . ‘</td>’;
echo ‘<td>’ . esc_html($test_title ?: ‘Test #’ . $r->test_id) . ‘</td>’;
echo ‘<td>’ . esc_html(strtoupper($r->module)) . ‘</td>’;
echo ‘<td>’ . esc_html($r->score) . ‘/’ . esc_html($r->total) . ‘</td>’;
echo ‘<td>’ . esc_html(strtoupper($r->status)) . ‘</td>’;
echo ‘<td><a class=”button” href=”‘ . esc_url($view_url) . ‘”>View</a></td>’;
echo ‘</tr>’;
}
echo ‘</tbody></table>’;
if (isset($_GET[‘view’])) {
$attempt_id = (int) $_GET[‘view’];
$attempt = $wpdb->get_row($wpdb->prepare(“SELECT * FROM $table WHERE id=%d”, $attempt_id));
if ($attempt) {
echo ‘<hr /><h2>Attempt Details #’ . esc_html($attempt->id) . ‘</h2>’;
echo ‘<p><strong>Student:</strong> ‘ . esc_html($attempt->student_id) . ‘</p>’;
echo ‘<p><strong>Test:</strong> ‘ . esc_html(get_the_title((int)$attempt->test_id)) . ‘</p>’;
echo ‘<p><strong>Status:</strong> ‘ . esc_html(strtoupper($attempt->status)) . ‘</p>’;
echo ‘<h3>Answers (JSON)</h3>’;
echo ‘<pre style=”background:#fff;border:1px solid #ddd;padding:12px;max-height:320px;overflow:auto;”>’ . esc_html($attempt->answers) . ‘</pre>’;
if ($attempt->status === ‘pending’) {
echo ‘<h3>Teacher Review</h3>’;
echo ‘<form method=”post” action=”‘.esc_url(admin_url(‘admin-post.php’)).’”>’;
echo ‘<input type=”hidden” name=”action” value=”ielts_mark_graded”>’;
echo ‘<input type=”hidden” name=”attempt_id” value=”‘.esc_attr($attempt->id).’”>’;
wp_nonce_field(‘ielts_mark_graded’);
echo ‘<p><label><strong>Band</strong></label><br>’;
echo ‘<input name=”band” placeholder=”e.g. 6.5″ style=”min-width:160px;padding:8px;”></p>’;
echo ‘<p><label><strong>Feedback</strong></label><br>’;
echo ‘<textarea name=”feedback” style=”width:100%;min-height:140px;”></textarea></p>’;
echo ‘<button class=”button button-primary” type=”submit”>Mark as Graded</button>’;
echo ‘</form>’;
} else {
if (!empty($attempt->teacher_feedback)) {
$tf = json_decode($attempt->teacher_feedback, true);
if (is_array($tf)) {
echo ‘<hr /><h3>Teacher Feedback</h3>’;
echo ‘<p><strong>Band:</strong> ‘ . esc_html($tf[‘band’] ?? ”) . ‘</p>’;
echo ‘<div style=”background:#fff;border:1px solid #ddd;border-radius:10px;padding:12px;white-space:pre-wrap;”>’ . wp_kses_post($tf[‘feedback’] ?? ”) . ‘</div>’;
}
}
}
}
}
echo ‘</div>’;
}
public function handle_mark_graded() {
if (!current_user_can(‘manage_options’)) wp_die(‘Unauthorized’);
check_admin_referer(‘ielts_mark_graded’);
$attempt_id = isset($_POST[‘attempt_id’]) ? (int) $_POST[‘attempt_id’] : 0;
$band = isset($_POST[‘band’]) ? sanitize_text_field($_POST[‘band’]) : ”;
$feedback = isset($_POST[‘feedback’]) ? wp_kses_post($_POST[‘feedback’]) : ”;
global $wpdb;
$table = $wpdb->prefix . ‘ielts_attempts’;
$wpdb->update($table, [
‘status’ => ‘graded’,
‘teacher_feedback’ => wp_json_encode([‘band’=>$band,’feedback’=>$feedback]),
], [‘id’=>$attempt_id]);
wp_redirect(admin_url(‘admin.php?page=ielts-grading-queue&view=’ . $attempt_id));
exit;
}
} // end class
new IELTS_Exam_LMS_MVP();