Compare commits
No commits in common. "f5b33c458dc4245e47af786f69de6a008bb9699e" and "bdc0f58abde39bda60416bbe58d3a6f11bd8505d" have entirely different histories.
f5b33c458d
...
bdc0f58abd
135
app.py
135
app.py
|
|
@ -642,7 +642,7 @@ def compile_code():
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': False,
|
'success': False,
|
||||||
'output': '',
|
'output': '',
|
||||||
'error': 'An error occurred: ' + str(e)
|
'error': f'An error occurred: {str(e)}'
|
||||||
})
|
})
|
||||||
|
|
||||||
@app.route('/static/<path:path>')
|
@app.route('/static/<path:path>')
|
||||||
|
|
@ -680,7 +680,7 @@ def login():
|
||||||
return jsonify({'success': False, 'message': 'Invalid token'})
|
return jsonify({'success': False, 'message': 'Invalid token'})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'message': 'Error processing login: ' + str(e)})
|
return jsonify({'success': False, 'message': f'Error processing login: {str(e)}'})
|
||||||
|
|
||||||
@app.route('/logout', methods=['POST'])
|
@app.route('/logout', methods=['POST'])
|
||||||
def logout():
|
def logout():
|
||||||
|
|
@ -695,7 +695,7 @@ def logout():
|
||||||
response.set_cookie('student_token', '', expires=0)
|
response.set_cookie('student_token', '', expires=0)
|
||||||
return response
|
return response
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'message': 'Error processing logout: ' + str(e)})
|
return jsonify({'success': False, 'message': f'Error processing logout: {str(e)}'})
|
||||||
|
|
||||||
@app.route('/validate-token', methods=['POST'])
|
@app.route('/validate-token', methods=['POST'])
|
||||||
def validate_token_route():
|
def validate_token_route():
|
||||||
|
|
@ -722,7 +722,7 @@ def validate_token_route():
|
||||||
return jsonify({'success': False, 'message': 'Invalid token'})
|
return jsonify({'success': False, 'message': 'Invalid token'})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'success': False, 'message': 'Error validating token: ' + str(e)})
|
return jsonify({'success': False, 'message': f'Error validating token: {str(e)}'})
|
||||||
|
|
||||||
@app.route('/track-progress', methods=['POST'])
|
@app.route('/track-progress', methods=['POST'])
|
||||||
def track_progress():
|
def track_progress():
|
||||||
|
|
@ -756,131 +756,8 @@ def track_progress():
|
||||||
return jsonify({'success': False, 'message': 'Failed to update progress'})
|
return jsonify({'success': False, 'message': 'Failed to update progress'})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error("Error in track-progress: " + str(e))
|
logging.error(f"Error in track-progress: {str(e)}")
|
||||||
return jsonify({'success': False, 'message': 'Error tracking progress: ' + str(e)})
|
return jsonify({'success': False, 'message': f'Error tracking progress: {str(e)}'})
|
||||||
|
|
||||||
|
|
||||||
def calculate_student_completion(student_data, all_lessons):
|
|
||||||
"""Calculate the number of completed lessons for a student"""
|
|
||||||
completed_count = 0
|
|
||||||
for lesson in all_lessons:
|
|
||||||
# Handle both the new structure and the old structure for compatibility
|
|
||||||
if isinstance(lesson, dict) and 'filename' in lesson:
|
|
||||||
lesson_key = lesson['filename'].replace('.md', '')
|
|
||||||
else:
|
|
||||||
# If lesson is just a string (fallback)
|
|
||||||
lesson_key = lesson.replace('.md', '')
|
|
||||||
|
|
||||||
if lesson_key in student_data and student_data[lesson_key] == 'completed':
|
|
||||||
completed_count += 1
|
|
||||||
return completed_count
|
|
||||||
|
|
||||||
@app.route('/progress-report')
|
|
||||||
def progress_report():
|
|
||||||
"""Display a report of all students' progress (from teacher's perspective)"""
|
|
||||||
# Get all students' progress from the CSV file
|
|
||||||
all_students_progress = []
|
|
||||||
lesson_headers = [] # Store the lesson column headers in the correct order
|
|
||||||
|
|
||||||
if os.path.exists(TOKENS_FILE):
|
|
||||||
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile:
|
|
||||||
reader = csv.DictReader(csvfile, delimiter=';')
|
|
||||||
# Get the fieldnames to determine the correct order of lessons
|
|
||||||
lesson_headers = [field for field in reader.fieldnames if field not in ['token', 'nama_siswa']]
|
|
||||||
|
|
||||||
# Get all lessons to map filenames to titles
|
|
||||||
all_lessons_dict = {}
|
|
||||||
for lesson in get_lessons_with_learning_objectives():
|
|
||||||
lesson_key = lesson['filename'].replace('.md', '')
|
|
||||||
all_lessons_dict[lesson_key] = lesson
|
|
||||||
|
|
||||||
# Create ordered lesson list based on CSV column order
|
|
||||||
ordered_lessons = []
|
|
||||||
for lesson_header in lesson_headers:
|
|
||||||
if lesson_header in all_lessons_dict:
|
|
||||||
ordered_lessons.append(all_lessons_dict[lesson_header])
|
|
||||||
else:
|
|
||||||
# If lesson not found in content directory, create a minimal entry
|
|
||||||
ordered_lessons.append({
|
|
||||||
'filename': f"{lesson_header}.md",
|
|
||||||
'title': lesson_header.replace('_', ' ').title(),
|
|
||||||
'description': 'Lesson information not available'
|
|
||||||
})
|
|
||||||
|
|
||||||
for row in reader:
|
|
||||||
# Create a copy of the row without the token
|
|
||||||
student_data = dict(row)
|
|
||||||
del student_data['token'] # Don't include token in the displayed data
|
|
||||||
# Add completion count to student data
|
|
||||||
student_data['completed_count'] = calculate_student_completion(student_data, ordered_lessons)
|
|
||||||
all_students_progress.append(student_data)
|
|
||||||
|
|
||||||
return render_template('progress_report.html',
|
|
||||||
all_students_progress=all_students_progress,
|
|
||||||
all_lessons=ordered_lessons,
|
|
||||||
app_bar_title=APP_BAR_TITLE,
|
|
||||||
copyright_text=COPYRIGHT_TEXT,
|
|
||||||
page_title_suffix=PAGE_TITLE_SUFFIX)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/progress-report/export-csv')
|
|
||||||
def export_progress_csv():
|
|
||||||
"""Export the progress report as CSV"""
|
|
||||||
# Get all students' progress from the CSV file
|
|
||||||
all_students_progress = []
|
|
||||||
lesson_headers = [] # Store the lesson column headers in the correct order
|
|
||||||
|
|
||||||
if os.path.exists(TOKENS_FILE):
|
|
||||||
with open(TOKENS_FILE, 'r', newline='', encoding='utf-8') as csvfile:
|
|
||||||
reader = csv.DictReader(csvfile, delimiter=';')
|
|
||||||
# Get the fieldnames to determine the correct order of lessons
|
|
||||||
lesson_headers = [field for field in reader.fieldnames if field not in ['token', 'nama_siswa']]
|
|
||||||
|
|
||||||
# Get all lessons to map filenames to titles
|
|
||||||
all_lessons_dict = {}
|
|
||||||
for lesson in get_lessons_with_learning_objectives():
|
|
||||||
lesson_key = lesson['filename'].replace('.md', '')
|
|
||||||
all_lessons_dict[lesson_key] = lesson
|
|
||||||
|
|
||||||
# Create ordered lesson list based on CSV column order
|
|
||||||
ordered_lessons = []
|
|
||||||
for lesson_header in lesson_headers:
|
|
||||||
if lesson_header in all_lessons_dict:
|
|
||||||
ordered_lessons.append(all_lessons_dict[lesson_header])
|
|
||||||
else:
|
|
||||||
# If lesson not found in content directory, create a minimal entry
|
|
||||||
ordered_lessons.append({
|
|
||||||
'filename': f"{lesson_header}.md",
|
|
||||||
'title': lesson_header.replace('_', ' ').title(),
|
|
||||||
'description': 'Lesson information not available'
|
|
||||||
})
|
|
||||||
|
|
||||||
for row in reader:
|
|
||||||
# Create a copy of the row without the token
|
|
||||||
student_data = dict(row)
|
|
||||||
del student_data['token'] # Don't include token in the exported data
|
|
||||||
# Add completion count to student data
|
|
||||||
student_data['completed_count'] = calculate_student_completion(student_data, ordered_lessons)
|
|
||||||
all_students_progress.append(student_data)
|
|
||||||
|
|
||||||
# Create CSV response
|
|
||||||
import io
|
|
||||||
from flask import Response
|
|
||||||
output = io.StringIO()
|
|
||||||
if all_students_progress:
|
|
||||||
# Write CSV header
|
|
||||||
fieldnames = list(all_students_progress[0].keys())
|
|
||||||
writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=';')
|
|
||||||
writer.writeheader()
|
|
||||||
writer.writerows(all_students_progress)
|
|
||||||
|
|
||||||
# Create response with CSV content
|
|
||||||
response = Response(
|
|
||||||
output.getvalue(),
|
|
||||||
mimetype='text/csv',
|
|
||||||
headers={"Content-Disposition": "attachment; filename=progress_report.csv"}
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_functions():
|
def inject_functions():
|
||||||
|
|
|
||||||
|
|
@ -14,44 +14,16 @@ TOKENS_FILE = '../tokens_siswa.csv'
|
||||||
|
|
||||||
def get_lesson_names():
|
def get_lesson_names():
|
||||||
"""Get all lesson names from the content directory (excluding home.md)"""
|
"""Get all lesson names from the content directory (excluding home.md)"""
|
||||||
# First, try to get the lesson order from home.md
|
|
||||||
home_file_path = os.path.join(CONTENT_DIR, "home.md")
|
|
||||||
lesson_names = []
|
|
||||||
|
|
||||||
if os.path.exists(home_file_path):
|
|
||||||
with open(home_file_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Split content to get only the lesson list part
|
|
||||||
parts = content.split('---Available_Lessons---')
|
|
||||||
if len(parts) > 1:
|
|
||||||
lesson_list_content = parts[1]
|
|
||||||
|
|
||||||
# Find lesson links in the lesson list content
|
|
||||||
import re
|
|
||||||
# Look for markdown links that point to lessons
|
|
||||||
lesson_links = re.findall(r'\[([^\]]+)\]\(lesson/([^\)]+)\)', lesson_list_content)
|
|
||||||
|
|
||||||
if lesson_links:
|
|
||||||
# Create ordered list based on home.md
|
|
||||||
for link_text, filename in lesson_links:
|
|
||||||
lesson_names.append(filename.replace('.md', ''))
|
|
||||||
|
|
||||||
return lesson_names
|
|
||||||
|
|
||||||
# If no specific order is defined in home.md, fall back to alphabetical order
|
|
||||||
lesson_files = glob.glob(os.path.join(CONTENT_DIR, "*.md"))
|
lesson_files = glob.glob(os.path.join(CONTENT_DIR, "*.md"))
|
||||||
|
lesson_names = []
|
||||||
|
|
||||||
for file_path in lesson_files:
|
for file_path in lesson_files:
|
||||||
filename = os.path.basename(file_path)
|
filename = os.path.basename(file_path)
|
||||||
# Skip home.md as it's not a lesson
|
# Skip home.md as it's not a lesson
|
||||||
if filename == "home.md":
|
if filename == "home.md":
|
||||||
continue
|
continue
|
||||||
lesson_names.append(filename.replace('.md', ''))
|
lesson_names.append(filename.replace('.md', ''))
|
||||||
|
|
||||||
# Sort alphabetically to have consistent order
|
|
||||||
lesson_names.sort()
|
|
||||||
|
|
||||||
return lesson_names
|
return lesson_names
|
||||||
|
|
||||||
def generate_tokens_csv():
|
def generate_tokens_csv():
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,6 @@
|
||||||
<button class="btn btn-outline-light logout-btn" type="button" style="display: none;">Logout</button>
|
<button class="btn btn-outline-light logout-btn" type="button" style="display: none;">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="student-info" class="text-light" style="margin-left: 15px; display: none;"></div>
|
<div id="student-info" class="text-light" style="margin-left: 15px; display: none;"></div>
|
||||||
{% if token %}
|
|
||||||
<a href="/progress-report?token={{ token }}" class="btn btn-outline-light ms-2">
|
|
||||||
<i class="fas fa-chart-bar"></i> Progress Report
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -41,11 +41,6 @@
|
||||||
<button class="btn btn-outline-light logout-btn" type="button" style="display: none;">Logout</button>
|
<button class="btn btn-outline-light logout-btn" type="button" style="display: none;">Logout</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="student-info" class="text-light" style="margin-left: 15px; display: none;"></div>
|
<div id="student-info" class="text-light" style="margin-left: 15px; display: none;"></div>
|
||||||
{% if token %}
|
|
||||||
<a href="/progress-report?token={{ token }}" class="btn btn-outline-light ms-2">
|
|
||||||
<i class="fas fa-chart-bar"></i> Progress Report
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -1,222 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Student Progress Report - {{ page_title_suffix }}</title>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('send_static', path='style.css') }}">
|
|
||||||
<style>
|
|
||||||
.progress-card {
|
|
||||||
border-radius: 10px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.table th {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
.status-completed {
|
|
||||||
background-color: #28a745;
|
|
||||||
color: white;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.status-not-started {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.status-in-progress {
|
|
||||||
background-color: #ffc107;
|
|
||||||
color: #212529;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.summary-stats {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 20px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
/* Additional table styling for better readability */
|
|
||||||
.table td, .table th {
|
|
||||||
vertical-align: middle;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.table th {
|
|
||||||
background-color: #495057;
|
|
||||||
color: white;
|
|
||||||
border: 1px solid #6c757d;
|
|
||||||
}
|
|
||||||
.table-striped tbody tr:nth-of-type(odd) {
|
|
||||||
background-color: rgba(0,0,0,.05);
|
|
||||||
}
|
|
||||||
.table-hover tbody tr:hover {
|
|
||||||
background-color: rgba(0,0,0,.075);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
|
||||||
<div class="container">
|
|
||||||
<a class="navbar-brand" href="/">
|
|
||||||
<i class="fas fa-chart-bar"></i> {{ app_bar_title }} - Progress Report
|
|
||||||
</a>
|
|
||||||
<div class="d-flex">
|
|
||||||
<form class="d-flex" id="token-form" style="display: flex; align-items: center;">
|
|
||||||
<input class="form-control me-2" type="text" id="teacher-token" placeholder="Enter teacher token" style="width: 200px;" disabled>
|
|
||||||
<button class="btn btn-outline-light" type="submit" style="display:none;">Login</button>
|
|
||||||
<button class="btn btn-outline-light logout-btn" type="button" style="display:none;">Logout</button>
|
|
||||||
</form>
|
|
||||||
<div id="teacher-info" class="text-light" style="margin-left: 15px;">
|
|
||||||
<i class="fas fa-chalkboard-teacher"></i> Teacher View
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="container mt-4">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
|
||||||
<h1><i class="fas fa-users"></i> Student Progress Report</h1>
|
|
||||||
<div>
|
|
||||||
<a href="/progress-report/export-csv" class="btn btn-success me-2">
|
|
||||||
<i class="fas fa-file-csv"></i> Export CSV
|
|
||||||
</a>
|
|
||||||
<button class="btn btn-info" onclick="window.print()">
|
|
||||||
<i class="fas fa-print"></i> Print Report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary-stats">
|
|
||||||
<div class="row text-center">
|
|
||||||
<div class="col-md-3 col-6 mb-3 mb-md-0">
|
|
||||||
<h3>{{ all_students_progress|length }}</h3>
|
|
||||||
<p class="mb-0">Total Students</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6 mb-3 mb-md-0">
|
|
||||||
<h3>{{ all_lessons|length }}</h3>
|
|
||||||
<p class="mb-0">Total Lessons</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6 mb-3 mb-md-0">
|
|
||||||
<h3 id="overall-completion">0%</h3>
|
|
||||||
<p class="mb-0">Overall Completion</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3 col-6">
|
|
||||||
<h3 id="total-completed">0</h3>
|
|
||||||
<p class="mb-0">Lessons Completed</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Students Progress Table -->
|
|
||||||
<div class="card progress-card">
|
|
||||||
<div class="card-header bg-primary text-white">
|
|
||||||
<h4 class="mb-0"><i class="fas fa-table"></i> Student Progress Overview</h4>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{% if all_students_progress %}
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-striped table-hover table-bordered">
|
|
||||||
<thead class="table-dark">
|
|
||||||
<tr>
|
|
||||||
<th scope="col">#</th>
|
|
||||||
<th scope="col">Student Name</th>
|
|
||||||
{% for lesson in all_lessons %}
|
|
||||||
<th scope="col" class="rotate-header" style="min-width: 120px;">
|
|
||||||
<div class="text-center" title="{{ lesson.title }}">{{ lesson.title[:12] }}{% if lesson.title|length > 12 %}..{% endif %}</div>
|
|
||||||
</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for student in all_students_progress %}
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{{ loop.index }}</th>
|
|
||||||
<td><strong>{{ student.nama_siswa }}</strong></td>
|
|
||||||
{% for lesson in all_lessons %}
|
|
||||||
{% set lesson_key = lesson.filename.replace('.md', '') %}
|
|
||||||
{% set status = student[lesson_key] if lesson_key in student else 'not_started' %}
|
|
||||||
<td class="text-center">
|
|
||||||
{% if status == 'completed' %}
|
|
||||||
<span class="status-completed" title="Completed">
|
|
||||||
<i class="fas fa-check"></i>
|
|
||||||
</span>
|
|
||||||
{% elif status == 'in_progress' %}
|
|
||||||
<span class="status-in-progress" title="In Progress">
|
|
||||||
<i class="fas fa-clock"></i>
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="status-not-started" title="Not Started">
|
|
||||||
<i class="fas fa-times"></i>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="alert alert-info text-center">
|
|
||||||
<h4><i class="fas fa-info-circle"></i> No student data available</h4>
|
|
||||||
<p>There are no students registered in the system yet.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="footer mt-5 py-4 bg-light">
|
|
||||||
<div class="container text-center">
|
|
||||||
<span class="text-muted">{{ copyright_text }}</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script>
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize tooltips for status indicators
|
|
||||||
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[title]'));
|
|
||||||
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
|
|
||||||
return new bootstrap.Tooltip(tooltipTriggerEl, {
|
|
||||||
trigger: 'hover'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate statistics dynamically
|
|
||||||
calculateStats();
|
|
||||||
});
|
|
||||||
|
|
||||||
function calculateStats() {
|
|
||||||
// Count total cells and completed cells
|
|
||||||
const allCells = document.querySelectorAll('td span.status-completed');
|
|
||||||
const totalCells = document.querySelectorAll('td span').length;
|
|
||||||
const completedCount = allCells.length;
|
|
||||||
|
|
||||||
// Update completed count
|
|
||||||
document.getElementById('total-completed').textContent = completedCount;
|
|
||||||
|
|
||||||
// Calculate and update completion percentage
|
|
||||||
if (totalCells > 0) {
|
|
||||||
const percentage = ((completedCount / totalCells) * 100).toFixed(1);
|
|
||||||
document.getElementById('overall-completion').textContent = percentage + '%';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,125 +0,0 @@
|
||||||
# Load Testing Guide for C Programming Learning Management System
|
|
||||||
|
|
||||||
This guide explains how to run load tests on the LMS-C application using Locust with different scenarios and configurations.
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
Before running load tests, ensure you have:
|
|
||||||
- Podman installed on your system
|
|
||||||
- The LMS-C application is properly configured with content and tokens
|
|
||||||
- The `tokens_siswa.csv` file contains student tokens (the load testing script will use these for realistic user simulation)
|
|
||||||
|
|
||||||
## Running Load Tests
|
|
||||||
|
|
||||||
### 1. Basic Load Test
|
|
||||||
|
|
||||||
To run a basic load test with the default configuration:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Navigate to the test directory
|
|
||||||
cd /path/to/lms-c/elemes/test
|
|
||||||
|
|
||||||
# Run the load test with default settings (10 simulated users)
|
|
||||||
podman-compose -f podman-compose.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
Then access the Locust web interface at `http://localhost:8089` and configure your test parameters.
|
|
||||||
|
|
||||||
### 2. Distributed Load Test
|
|
||||||
|
|
||||||
For larger-scale testing, you can run a distributed test with multiple workers:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Start the master node
|
|
||||||
podman-compose -f podman-compose.yml up --scale worker=3 master
|
|
||||||
|
|
||||||
# Or run with specific environment variables
|
|
||||||
TARGET_URL=http://your-lms-url.com LOCUST_NUM_STUDENTS=100 podman-compose -f podman-compose.yml up --scale worker=3 master
|
|
||||||
```
|
|
||||||
|
|
||||||
This will start 1 master and 3 worker nodes to distribute the load.
|
|
||||||
|
|
||||||
### 3. Environment Variables
|
|
||||||
|
|
||||||
The load testing configuration can be customized using these environment variables:
|
|
||||||
|
|
||||||
- `TARGET_URL`: The URL of the LMS-C application to test (default: `http://example.com`)
|
|
||||||
- `LOCUST_NUM_STUDENTS`: Number of simulated students/users (default: 10, but will automatically detect from tokens_siswa.csv)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```bash
|
|
||||||
TARGET_URL=http://192.168.1.100:5000 LOCUST_NUM_STUDENTS=50 podman-compose -f podman-compose.yml up --build
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Customizing Test Scenarios
|
|
||||||
|
|
||||||
The `locustfile.py` implements several user behavior patterns:
|
|
||||||
|
|
||||||
#### WebsiteUser Class
|
|
||||||
- Simulates basic website visitors
|
|
||||||
- Performs tasks like viewing homepage, lessons, and compiling code
|
|
||||||
- Weight: 1 (less frequent)
|
|
||||||
|
|
||||||
#### AdvancedUser Class
|
|
||||||
- Simulates engaged students who actively participate
|
|
||||||
- Performs more complex behaviors like lesson navigation and intensive code compilation
|
|
||||||
- Weight: 2 (twice as likely to be chosen as WebsiteUser)
|
|
||||||
|
|
||||||
#### Task Distribution
|
|
||||||
- `view_homepage`: Weight 3 (most common action)
|
|
||||||
- `compile_code`: Weight 4 (very common action)
|
|
||||||
- `view_lesson`: Weight 2 (common action)
|
|
||||||
- `login_student`: Weight 1 (less frequent but important)
|
|
||||||
- `validate_token`: Weight 1 (essential for tracking)
|
|
||||||
- `track_progress`: Weight 1 (important for completion tracking)
|
|
||||||
|
|
||||||
### 5. Realistic Student Simulation
|
|
||||||
|
|
||||||
The load testing script reads from `tokens_siswa.csv` to simulate real students:
|
|
||||||
- Each simulated user gets assigned a real student token from the CSV
|
|
||||||
- This ensures realistic progress tracking behavior
|
|
||||||
- The number of simulated users should match or be proportional to the number of tokens in the CSV
|
|
||||||
|
|
||||||
### 6. Monitoring and Analysis
|
|
||||||
|
|
||||||
Access the Locust web interface at `http://localhost:8089` to:
|
|
||||||
- Configure the number of users and spawn rate
|
|
||||||
- Monitor real-time statistics
|
|
||||||
- View response times, failure rates, and throughput
|
|
||||||
- Download test reports
|
|
||||||
|
|
||||||
### 7. Running Without the Web Interface
|
|
||||||
|
|
||||||
You can also run Locust in headless mode:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run with specific parameters without web UI
|
|
||||||
podman run -v $(pwd)/..:/mnt/locust \
|
|
||||||
-e LOCUST_HOST=http://your-target-url.com \
|
|
||||||
-e LOCUST_USERS=100 \
|
|
||||||
-e LOCUST_SPAWN_RATE=10 \
|
|
||||||
-e LOCUST_RUN_TIME=10m \
|
|
||||||
locustio/locust -f /mnt/locust/elemes/test/locustfile.py --headless
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Scaling Recommendations
|
|
||||||
|
|
||||||
- For 1-50 concurrent users: Single master node is sufficient
|
|
||||||
- For 50-200 concurrent users: Use 1 master + 2-3 worker nodes
|
|
||||||
- For 200+ concurrent users: Scale workers proportionally (1 master + 5+ workers)
|
|
||||||
|
|
||||||
### 9. Best Practices
|
|
||||||
|
|
||||||
- Always test against a staging environment that mirrors production
|
|
||||||
- Gradually increase the number of users to identify performance bottlenecks
|
|
||||||
- Monitor server resources (CPU, memory, disk I/O) during tests
|
|
||||||
- Run tests multiple times to account for variations
|
|
||||||
- Clean up resources after testing to avoid unnecessary resource consumption
|
|
||||||
|
|
||||||
### 10. Troubleshooting
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
- Ensure the target LMS-C application is accessible from the Locust containers
|
|
||||||
- Check that the `tokens_siswa.csv` file is properly mounted and readable
|
|
||||||
- Verify that the content directory has lesson files for realistic testing
|
|
||||||
- Monitor container logs with `podman logs -f <container-name>`
|
|
||||||
|
|
@ -307,8 +307,6 @@ class WebsiteUser(HttpUser):
|
||||||
pass # Ignore logout errors
|
pass # Ignore logout errors
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Additional task sets for more complex behaviors
|
# Additional task sets for more complex behaviors
|
||||||
|
|
||||||
class LessonNavigationTaskSet(TaskSet):
|
class LessonNavigationTaskSet(TaskSet):
|
||||||
|
|
@ -508,253 +506,4 @@ class AdvancedUser(HttpUser):
|
||||||
weight = 2 # Twice as likely to be chosen as WebsiteUser
|
weight = 2 # Twice as likely to be chosen as WebsiteUser
|
||||||
tasks = {LessonNavigationTaskSet: 2, CompilationFocusedTaskSet: 3, LMSCUserBehavior: 4}
|
tasks = {LessonNavigationTaskSet: 2, CompilationFocusedTaskSet: 3, LMSCUserBehavior: 4}
|
||||||
|
|
||||||
wait_time = between(0.5, 2)
|
wait_time = between(0.5, 2)
|
||||||
|
|
||||||
|
|
||||||
class SessionBasedUser(HttpUser):
|
|
||||||
"""
|
|
||||||
User that simulates a complete learning session with focused behavior
|
|
||||||
"""
|
|
||||||
weight = 1
|
|
||||||
tasks = [LMSCUserBehavior]
|
|
||||||
|
|
||||||
wait_time = between(2, 5)
|
|
||||||
|
|
||||||
def on_start(self):
|
|
||||||
"""
|
|
||||||
Initialize a complete learning session
|
|
||||||
"""
|
|
||||||
# Login at the beginning of the session
|
|
||||||
tokens_file = '/mnt/locust/tokens_siswa.csv'
|
|
||||||
all_students = []
|
|
||||||
|
|
||||||
if os.path.exists(tokens_file):
|
|
||||||
with open(tokens_file, 'r', newline='', encoding='utf-8') as csvfile:
|
|
||||||
reader = csv.DictReader(csvfile, delimiter=';')
|
|
||||||
all_students = list(reader)
|
|
||||||
|
|
||||||
if all_students:
|
|
||||||
selected_student = random.choice(all_students)
|
|
||||||
self.student_token = selected_student.get('token', f"STUDENT_TOKEN_{random.randint(1000, 9999)}")
|
|
||||||
self.student_name = selected_student.get('nama_siswa', f"Student_{random.randint(1000, 9999)}")
|
|
||||||
else:
|
|
||||||
self.student_token = f"STUDENT_TOKEN_{random.randint(1000, 9999)}"
|
|
||||||
self.student_name = f"Student_{random.randint(1000, 9999)}"
|
|
||||||
|
|
||||||
# Login the student
|
|
||||||
login_payload = {
|
|
||||||
"token": self.student_token
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.client.post("/login", json=login_payload)
|
|
||||||
except:
|
|
||||||
pass # Continue even if login fails
|
|
||||||
|
|
||||||
# Read lesson files
|
|
||||||
content_dir = '/mnt/locust/content'
|
|
||||||
self.lesson_files = []
|
|
||||||
|
|
||||||
if os.path.exists(content_dir):
|
|
||||||
lesson_paths = glob.glob(os.path.join(content_dir, "*.md"))
|
|
||||||
self.lesson_files = [os.path.basename(path) for path in lesson_paths if os.path.isfile(path)]
|
|
||||||
|
|
||||||
|
|
||||||
class BehaviorAnalysisTaskSet(TaskSet):
|
|
||||||
"""
|
|
||||||
Task set for analyzing user behavior patterns
|
|
||||||
"""
|
|
||||||
|
|
||||||
def on_start(self):
|
|
||||||
"""
|
|
||||||
Initialize with student data
|
|
||||||
"""
|
|
||||||
tokens_file = '/mnt/locust/tokens_siswa.csv'
|
|
||||||
all_students = []
|
|
||||||
|
|
||||||
if os.path.exists(tokens_file):
|
|
||||||
with open(tokens_file, 'r', newline='', encoding='utf-8') as csvfile:
|
|
||||||
reader = csv.DictReader(csvfile, delimiter=';')
|
|
||||||
all_students = list(reader)
|
|
||||||
|
|
||||||
if all_students:
|
|
||||||
selected_student = random.choice(all_students)
|
|
||||||
self.student_token = selected_student.get('token', f"STUDENT_TOKEN_{random.randint(1000, 9999)}")
|
|
||||||
self.student_name = selected_student.get('nama_siswa', f"Student_{random.randint(1000, 9999)}")
|
|
||||||
else:
|
|
||||||
self.student_token = f"STUDENT_TOKEN_{random.randint(1000, 9999)}"
|
|
||||||
self.student_name = f"Student_{random.randint(1000, 9999)}"
|
|
||||||
|
|
||||||
# Read lesson files
|
|
||||||
content_dir = '/mnt/locust/content'
|
|
||||||
self.lesson_files = []
|
|
||||||
|
|
||||||
if os.path.exists(content_dir):
|
|
||||||
lesson_paths = glob.glob(os.path.join(content_dir, "*.md"))
|
|
||||||
self.lesson_files = [os.path.basename(path) for path in lesson_paths if os.path.isfile(path)]
|
|
||||||
|
|
||||||
@task(3)
|
|
||||||
def analyze_learning_pattern(self):
|
|
||||||
"""
|
|
||||||
Simulate a learning pattern where a student goes through multiple lessons in sequence
|
|
||||||
"""
|
|
||||||
if not self.lesson_files:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Select a few lessons to go through in sequence
|
|
||||||
selected_lessons = random.sample(self.lesson_files, min(3, len(self.lesson_files)))
|
|
||||||
|
|
||||||
for lesson in selected_lessons:
|
|
||||||
# Visit the lesson
|
|
||||||
self.client.get(f"/lesson/{lesson}?token={self.student_token}")
|
|
||||||
|
|
||||||
# Spend some time reading (simulate wait)
|
|
||||||
self.wait()
|
|
||||||
|
|
||||||
# Try to compile code from the lesson
|
|
||||||
self.compile_code_from_lesson(lesson)
|
|
||||||
|
|
||||||
# Track progress for the lesson
|
|
||||||
lesson_name = lesson.replace('.md', '')
|
|
||||||
progress_payload = {
|
|
||||||
"token": self.student_token,
|
|
||||||
"lesson_name": lesson_name,
|
|
||||||
"status": "in_progress"
|
|
||||||
}
|
|
||||||
self.client.post("/track-progress", json=progress_payload)
|
|
||||||
|
|
||||||
# Wait between activities
|
|
||||||
self.wait()
|
|
||||||
|
|
||||||
# Mark final lesson as completed
|
|
||||||
if selected_lessons:
|
|
||||||
final_lesson = selected_lessons[-1].replace('.md', '')
|
|
||||||
progress_payload = {
|
|
||||||
"token": self.student_token,
|
|
||||||
"lesson_name": final_lesson,
|
|
||||||
"status": "completed"
|
|
||||||
}
|
|
||||||
self.client.post("/track-progress", json=progress_payload)
|
|
||||||
|
|
||||||
def compile_code_from_lesson(self, lesson_file):
|
|
||||||
"""
|
|
||||||
Attempt to compile code from a specific lesson
|
|
||||||
"""
|
|
||||||
lesson_path = f'/mnt/locust/content/{lesson_file}'
|
|
||||||
|
|
||||||
if not os.path.exists(lesson_path):
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(lesson_path, 'r', encoding='utf-8') as f:
|
|
||||||
content = f.read()
|
|
||||||
|
|
||||||
# Look for code sections in the lesson
|
|
||||||
initial_code = self.extract_code_section(content, '---INITIAL_CODE---', '---END_INITIAL_CODE---')
|
|
||||||
solution_code = self.extract_code_section(content, '---SOLUTION_CODE---', '---END_SOLUTION_CODE---')
|
|
||||||
|
|
||||||
# Use available code for compilation
|
|
||||||
code_to_compile = None
|
|
||||||
if solution_code:
|
|
||||||
code_to_compile = solution_code
|
|
||||||
elif initial_code:
|
|
||||||
code_to_compile = initial_code
|
|
||||||
|
|
||||||
if code_to_compile:
|
|
||||||
programming_language = os.environ.get('DEFAULT_PROGRAMMING_LANGUAGE', 'c')
|
|
||||||
|
|
||||||
compile_payload = {
|
|
||||||
"code": code_to_compile,
|
|
||||||
"language": programming_language
|
|
||||||
}
|
|
||||||
|
|
||||||
response = self.client.post("/compile", json=compile_payload)
|
|
||||||
|
|
||||||
if response.status_code == 200:
|
|
||||||
result = response.json()
|
|
||||||
if not result.get("success"):
|
|
||||||
print(f"Compilation error in lesson {lesson_file}: {result.get('error')}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error compiling code from lesson {lesson_file}: {str(e)}")
|
|
||||||
|
|
||||||
def extract_code_section(self, content, start_marker, end_marker):
|
|
||||||
"""
|
|
||||||
Extract code between start and end markers
|
|
||||||
"""
|
|
||||||
start_idx = content.find(start_marker)
|
|
||||||
end_idx = content.find(end_marker)
|
|
||||||
|
|
||||||
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
|
|
||||||
start_pos = start_idx + len(start_marker)
|
|
||||||
extracted_code = content[start_pos:end_idx].strip()
|
|
||||||
return extracted_code
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class PowerUser(HttpUser):
|
|
||||||
"""
|
|
||||||
Power user that exhibits intensive usage patterns
|
|
||||||
"""
|
|
||||||
weight = 1
|
|
||||||
tasks = {BehaviorAnalysisTaskSet: 3, CompilationFocusedTaskSet: 4, LMSCUserBehavior: 2}
|
|
||||||
|
|
||||||
wait_time = between(0.2, 1.5)
|
|
||||||
|
|
||||||
|
|
||||||
class TeacherUser(HttpUser):
|
|
||||||
"""
|
|
||||||
Teacher user that accesses the progress report feature
|
|
||||||
"""
|
|
||||||
weight = 1
|
|
||||||
tasks = [LMSCUserBehavior]
|
|
||||||
|
|
||||||
wait_time = between(2, 5)
|
|
||||||
|
|
||||||
def on_start(self):
|
|
||||||
"""
|
|
||||||
Initialize teacher session
|
|
||||||
"""
|
|
||||||
# Teachers don't need a specific token to view progress report
|
|
||||||
# They can access the progress report page to see all students' progress
|
|
||||||
pass
|
|
||||||
|
|
||||||
@task(2)
|
|
||||||
def view_progress_report(self):
|
|
||||||
"""
|
|
||||||
Task to view the student progress report
|
|
||||||
"""
|
|
||||||
# Access the progress report page
|
|
||||||
response = self.client.get("/progress-report")
|
|
||||||
|
|
||||||
# Check if the response is successful
|
|
||||||
if response.status_code == 200:
|
|
||||||
print("Successfully accessed progress report page")
|
|
||||||
else:
|
|
||||||
print(f"Failed to access progress report page: {response.status_code}")
|
|
||||||
|
|
||||||
@task(1)
|
|
||||||
def export_progress_csv(self):
|
|
||||||
"""
|
|
||||||
Task to export the progress report as CSV
|
|
||||||
"""
|
|
||||||
# Export the progress report as CSV
|
|
||||||
response = self.client.get("/progress-report/export-csv")
|
|
||||||
|
|
||||||
# Check if the response is successful
|
|
||||||
if response.status_code == 200:
|
|
||||||
print("Successfully exported progress report as CSV")
|
|
||||||
else:
|
|
||||||
print(f"Failed to export progress report as CSV: {response.status_code}")
|
|
||||||
|
|
||||||
@task(1)
|
|
||||||
def view_homepage_as_teacher(self):
|
|
||||||
"""
|
|
||||||
Task for teacher to view homepage
|
|
||||||
"""
|
|
||||||
# Teachers might also view the homepage
|
|
||||||
self.client.get("/")
|
|
||||||
|
|
||||||
|
|
||||||
# Update the user_classes list to include the new TeacherUser
|
|
||||||
user_classes = [WebsiteUser, AdvancedUser, SessionBasedUser, PowerUser, TeacherUser]
|
|
||||||
Loading…
Reference in New Issue