Compare commits

...

18 Commits

Author SHA1 Message Date
ba851cb7dc Added analysis UI 2023-03-05 00:33:15 +00:00
fcc4d55947 Delete redundant lines 2023-03-05 00:32:15 +00:00
a56358b8dd Added question parser for analysis 2023-03-05 00:31:33 +00:00
179a608089 Made randomising of question order optional 2023-03-05 00:29:25 +00:00
a1289da09c Added analysis button and scripting 2023-03-05 00:28:54 +00:00
ea86fd9ae6 Updated parameter for new library version 2023-03-05 00:27:30 +00:00
76d60546e2 Cleared whitespace 2023-03-05 00:27:03 +00:00
9a02048199 Added get_file method to datasets 2023-03-05 00:26:39 +00:00
c9ad8e87cd Bugfix: rendering htm elements in results page 2023-03-04 19:48:49 +00:00
3714919ba5 Add analysis function 2023-03-04 18:56:34 +00:00
1026cc71a9 Add dataset to entry on creation 2023-03-04 18:56:10 +00:00
07fb170656 Add dataset and entry relation to database models 2023-03-04 18:55:30 +00:00
1ea93994ab Load and register analysis module blueprints 2023-03-04 18:54:47 +00:00
607b132996 Removed passing of unused values 2023-03-04 18:54:02 +00:00
7aa4d81e65 Corrected indentation 2023-03-04 18:53:02 +00:00
0ef39dcfbe Update navbars 2023-03-04 18:52:13 +00:00
e1517b89c0 Add analysis module 2023-03-04 18:51:50 +00:00
d0ed228824 Updated libraries 2023-03-04 18:49:35 +00:00
37 changed files with 1356 additions and 49 deletions

View File

@ -51,6 +51,7 @@ def create_app():
from .views import views from .views import views
from .editor.views import editor from .editor.views import editor
from .view.views import view from .view.views import view
from .analysis.views import analysis
app.register_blueprint(admin, url_prefix='/admin') app.register_blueprint(admin, url_prefix='/admin')
app.register_blueprint(api, url_prefix='/api') app.register_blueprint(api, url_prefix='/api')
@ -58,6 +59,7 @@ def create_app():
app.register_blueprint(quiz) app.register_blueprint(quiz)
app.register_blueprint(editor, url_prefix='/admin/editor') app.register_blueprint(editor, url_prefix='/admin/editor')
app.register_blueprint(view, url_prefix='/admin/view') app.register_blueprint(view, url_prefix='/admin/view')
app.register_blueprint(analysis, url_prefix='/admin/analysis')
"""Create Database Tables before First Request""" """Create Database Tables before First Request"""
@app.before_first_request @app.before_first_request

View File

@ -88,6 +88,19 @@ $('.test-action').click(function(event) {
}) })
} else if (action == 'edit') { } else if (action == 'edit') {
window.location.href = `/admin/test/${id}/` window.location.href = `/admin/test/${id}/`
} else if (action == 'analyse') {
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': 'test'}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
} }
event.preventDefault() event.preventDefault()
@ -123,6 +136,19 @@ $('.edit-question-dataset').click(function(event) {
window.location.href = `/admin/view/${id}` window.location.href = `/admin/view/${id}`
} else if (action == 'download') { } else if (action == 'download') {
window.location.href = `/admin/settings/questions/download/${id}/` window.location.href = `/admin/settings/questions/download/${id}/`
} else if (action == 'analyse') {
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': 'dataset'}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
} }
} }
event.preventDefault() event.preventDefault()

View File

@ -20,8 +20,28 @@
</li> </li>
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item" id="nav-results"> <li class="nav-item dropdown" id="nav-results">
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a> <a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li> </li>
<li class="nav-item dropdown" id="nav-tests"> <li class="nav-item dropdown" id="nav-tests">
<a <a
@ -36,7 +56,7 @@
</a> </a>
<ul <ul
class="dropdown-menu" class="dropdown-menu"
aria-labelledby="dropdown-settings" aria-labelledby="dropdown-tests"
> >
<li> <li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a> <a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
@ -58,7 +78,7 @@
<li class="nav-item dropdown" id="nav-settings"> <li class="nav-item dropdown" id="nav-settings">
<a <a
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
id="dropdown-account" id="dropdown-settings"
role="button" role="button"
href="{{ url_for('admin._settings') }}" href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"

View File

@ -108,7 +108,7 @@
{% for tag, scores in entry.result.tags.items() %} {% for tag, scores in entry.result.tags.items() %}
<tr> <tr>
<td> <td>
{{ tag }} {{ tag|safe }}
</td> </td>
<td> <td>
{{ scores.scored }} {{ scores.scored }}

View File

@ -52,6 +52,15 @@
{{ element.tests|length }} {{ element.tests|length }}
</td> </td>
<td class="row-actions"> <td class="row-actions">
<a
href="javascript:void(0)"
class="btn btn-success edit-question-dataset {% if not element.entries %} disabled {% endif %}"
data-id="{{ element.id }}"
data-action="analyse"
title="Analyse Answers"
>
<i class="bi bi-search button-icon"></i>
</a>
<a <a
href="javascript:void(0)" href="javascript:void(0)"
class="btn btn-primary edit-question-dataset" class="btn btn-primary edit-question-dataset"
@ -63,7 +72,7 @@
</a> </a>
<a <a
href="javascript:void(0)" href="javascript:void(0)"
class="btn btn-primary view-question-dataset" class="btn btn-primary edit-question-dataset"
data-id="{{ element.id }}" data-id="{{ element.id }}"
data-action="view" data-action="view"
title="View Questions" title="View Questions"

View File

@ -162,7 +162,7 @@
{% include "admin/components/client-alerts.html" %} {% include "admin/components/client-alerts.html" %}
</div> </div>
<div class="container justify-content-center"> <div class="container justify-content-center">
<div class="row"> <div class="my-3 row">
{% if test.start_date <= now %} {% if test.start_date <= now %}
<a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}"> <a href="#" class="btn btn-warning test-action {% if test.end_date < now %}disabled{% endif %}" data-action="end" data-id="{{ test.id }}">
<i class="bi bi-hourglass-bottom button-icon"></i> <i class="bi bi-hourglass-bottom button-icon"></i>
@ -174,6 +174,16 @@
Start Exam Start Exam
</a> </a>
{% endif %} {% endif %}
<a
href="#"
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
data-id="{{test.id}}"
title="Analyse Exam"
data-action="analyse"
>
<i class="bi bi-search button-icon"></i>
Analyse Exam
</a>
<a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}"> <a href="#" class="btn btn-danger test-action" data-action="delete" data-id="{{ test.id }}">
<i class="bi bi-file-earmark-excel-fill button-icon"></i> <i class="bi bi-file-earmark-excel-fill button-icon"></i>
Delete Exam Delete Exam

View File

@ -58,6 +58,15 @@
{{ test.entries|length }} {{ test.entries|length }}
</td> </td>
<td class="row-actions"> <td class="row-actions">
<a
href="#"
class="btn btn-success test-analyse test-action {% if not test.entries %} disabled {% endif %}"
data-id="{{test.id}}"
title="Analyse Exam"
data-action="analyse"
>
<i class="bi bi-search button-icon"></i>
</a>
<a <a
href="#" href="#"
class="btn btn-primary test-action" class="btn btn-primary test-action"

View File

@ -251,7 +251,6 @@ def _questions():
if success: return jsonify({'success': message}), 200 if success: return jsonify({'success': message}), 200
return jsonify({'error': message}), 400 return jsonify({'error': message}), 400
return send_errors_to_client(form=form) return send_errors_to_client(form=form)
try: data = Dataset.query.all() try: data = Dataset.query.all()
except Exception as exception: except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
@ -281,7 +280,7 @@ def _download(id:str):
return abort(500) return abort(500)
if not dataset: return abort(404) if not dataset: return abort(404)
data_path = path.abspath(dataset.get_file()) data_path = path.abspath(dataset.get_file())
return send_file(data_path, as_attachment=True, attachment_filename=f'{dataset.get_name()}.json') return send_file(data_path, as_attachment=True, download_name=f'{dataset.get_name()}.json')
@admin.route('/tests/<string:filter>/', methods=['GET']) @admin.route('/tests/<string:filter>/', methods=['GET'])
@admin.route('/tests/', methods=['GET']) @admin.route('/tests/', methods=['GET'])
@ -435,10 +434,7 @@ def _view_entry(id:str=None):
flash('Invalid entry ID.', 'error') flash('Invalid entry ID.', 'error')
return redirect(url_for('admin._view_entries')) return redirect(url_for('admin._view_entries'))
test = entry.test test = entry.test
dataset = test.dataset data = test.dataset.get_data()
dataset_path = dataset.get_file()
with open(dataset_path, 'r') as _dataset:
data = loads(_dataset.read())
correct = get_correct_answers(dataset=data) correct = get_correct_answers(dataset=data)
answers = answer_options(dataset=data) answers = answer_options(dataset=data)
return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers) return render_template('/admin/result-detail.html', entry = entry, correct = correct, answers = answers)

View File

View File

@ -0,0 +1,8 @@
#alert-box {
margin: 30px auto;
max-width: 460px;
}
.cell-percentage::after {
content: '%';
}

View File

@ -0,0 +1,260 @@
body {
padding: 80px 0;
}
.site-footer {
background-color: lightgray;
font-size: small;
}
.site-footer p {
margin: 0;
}
.form-container {
display: -ms-flexbox;
display: flex;
-ms-flex-align: center;
align-items: center;
padding-top: 40px;
padding-bottom: 40px;
}
.form-display {
width: 100%;
max-width: 420px;
padding: 15px;
margin: auto;
}
.form-heading {
margin-bottom: 2rem;
}
.form-label-group {
position: relative;
margin-bottom: 2rem;
}
.form-label-group input,
.form-label-group label {
padding: var(--input-padding-y) var(--input-padding-x);
font-size: 16pt;
}
.form-label-group label {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
margin-bottom: 0; /* Override default `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text; /* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
z-index: -1;
}
.form-label-group input {
background-color: transparent;
border: none;
border-radius: 0%;
border-bottom: 2px solid #585858;
}
.form-label-group input:active, .form-label-group input:focus {
background-color: transparent;
}
.form-label-group input::-webkit-input-placeholder {
color: transparent;
}
.form-label-group input:-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-ms-input-placeholder {
color: transparent;
}
.form-label-group input::-moz-placeholder {
color: transparent;
}
.form-label-group input::placeholder {
color: transparent;
}
.form-label-group input:not(:placeholder-shown) {
padding-top: calc(var(--input-padding-y) + var(--input-padding-y) * (2 / 3));
padding-bottom: calc(var(--input-padding-y) / 3);
}
.form-label-group input:not(:placeholder-shown) ~ label {
padding-top: calc(var(--input-padding-y) / 3);
padding-bottom: calc(var(--input-padding-y) / 3);
font-size: 12px;
color: #777;
}
.form-check {
margin-bottom: 2rem;
}
.checkbox input {
transform: scale(1.5);
margin-right: 1rem;
}
.signin-forgot-password {
font-size: 14pt;
}
.form-submission-button {
margin-bottom: 2rem;
}
.form-submission-button button, .form-submission-button a {
margin: 1rem;
vertical-align: middle;
}
.form-submission-button button span, .form-submission-button button svg, .form-submission-button a span, .form-submission-button a svg {
margin: 0 2px;
}
table.dataTable {
border-collapse: collapse;
width: 100%;
}
.table-row {
vertical-align: middle;
}
.row-actions {
text-align: center;
white-space: nowrap;
}
.dataTables_wrapper .dt-buttons {
left: 50%;
transform: translateX(-50%);
float:none;
text-align:center;
}
.row-actions button, .row-actions a {
margin: 0px 5px;
}
#cookie-alert {
padding-right: 16px;
}
#dismiss-cookie-alert {
margin-top: 16px;
width: fit-content;
}
.alert-db-empty {
width: 100%;
max-width: 720px;
font-size: 14pt;
margin: 20px auto;
}
.form-date-input, .form-select-input {
position: relative;
margin: 2rem 0;
}
.form-date-input input,
.form-date-input label, .form-select-input select, .form-select-input label {
padding: var(--input-padding-y) var(--input-padding-x);
font-size: 16pt;
width: 100%;
background-color: transparent;
border: none;
border-bottom: 2px solid #585858;
}
.datepicker::-webkit-calendar-picker-indicator {
border: 1px;
border-color: gray;
border-radius: 10%;
}
.form-date-input label, .form-select-input label {
/* position: absolute; */
/* top: 0;
left: 0; */
display: block;
width: 100%;
margin-bottom: 0; /* Override default `<label>` margin */
line-height: 1.5;
color: #495057;
cursor: text; /* Match the input under the label */
border: 1px solid transparent;
border-radius: .25rem;
transition: all .1s ease-in-out;
z-index: -1;
}
.button-icon {
font-size: 20px;
}
.form-upload {
margin: 2rem 0;
font-size: 14pt;
}
.result-action-buttons, .test-action {
margin: 5px auto;
width: fit-content;
}
.accordion-item {
background-color: unset;
}
/* Change Autocomplete styles in Chrome*/
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
transition: background-color 5000s ease-in-out 0s;
}
/* Fallback for Edge
-------------------------------------------------- */
@supports (-ms-ime-align: auto) {
.form-label-group label {
display: none;
}
.form-label-group input::-ms-input-placeholder {
color: #777;
}
}
/* Fallback for IE
-------------------------------------------------- */
@media all and (-ms-high-contrast: none), (-ms-high-contrast: active) {
.form-label-group label {
display: none;
}
.form-label-group input:-ms-input-placeholder {
color: #777;
}
}

View File

@ -0,0 +1,27 @@
// Analyse Button Listener
$('.button-analyse').click(function(event) {
let buttonClass = $(this).data('class')
let id = null
if (buttonClass == 'test' ) {
id = $('#select-test').children('option:selected').val()
} else if (buttonClass == 'dataset' ) {
id = $('#select-dataset').children('option:selected').val()
}
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': buttonClass}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
event.preventDefault()
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,115 @@
// Menu Highlight Scripts
const menuItems = document.getElementsByClassName('nav-link')
for(let i = 0; i < menuItems.length; i++) {
if(menuItems[i].pathname == window.location.pathname) {
menuItems[i].classList.add('active')
}
}
const dropdownItems = document.getElementsByClassName('dropdown-item')
for(let i = 0; i< dropdownItems.length; i++) {
if(dropdownItems[i].pathname == window.location.pathname) {
dropdownItems[i].classList.add('active')
$( "#" + dropdownItems[i].id ).closest( '.dropdown' ).find('.dropdown-toggle').addClass('active')
}
}
// General Post Method Form Processing Script
$('form.form-post').submit(function(event) {
var $form = $(this)
var data = $form.serialize()
var url = $(this).prop('action')
var rel_success = $(this).data('rel-success')
$.ajax({
url: url,
type: 'POST',
data: data,
dataType: 'json',
success: function(response) {
if (response.redirect_to) {
window.location.href = response.redirect_to
}
else {
window.location.href = rel_success
}
},
error: function(response) {
error_response(response)
}
})
event.preventDefault()
})
function error_response(response) {
const $alert = $("#alert-box")
$alert.html('')
if (typeof response.responseJSON.error === 'string' || response.responseJSON.error instanceof String) {
$alert.html(`
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`)
} else if (response.responseJSON.error instanceof Array) {
var output = ''
for (let i = 0; i < response.responseJSON.error.length; i ++) {
output += `
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Danger"></i>
${response.responseJSON.error[i]}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`
$alert.html(output)
}
}
$alert.focus()
}
// Dismiss Cookie Alert
$('#dismiss-cookie-alert').click(function(event){
$.ajax({
url: '/cookies/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
console.log(response)
},
error: function(response){
console.log(response)
}
})
event.preventDefault()
})
// Create New Dataset
$('.create-new-dataset').click(function(event){
$.ajax({
url: '/api/editor/new/',
type: 'POST',
data: {
time: Date.now()
},
dataType: 'json',
success: function(response){
if (response.redirect_to) {
window.location.href = response.redirect_to
}
},
error: function(response){
console.log(response)
}
})
event.preventDefault()
})

View File

@ -0,0 +1,170 @@
{% extends "analysis/components/datatable.html" %}
{% block style %}
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/analysis.css') }}"
/>
{% endblock %}
{% block content %}
<h1>Analysis</h1>
<div class="container">
<p class="lead">
Analysis for {{ type }} {{ subject }}.
</p>
</div>
<div class="container">
<h3>
Question List
</h3>
<div class="container dataset-metadata">
<div class="input-group mb-3">
<span class="input-group-text">Dataset Name</span>
<span class="form-control">
{{ dataset.get_name() }}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Author</span>
<span class="form-control">
{{ dataset.creator.get_username() }}
</span>
</div>
<div class="input-group mb-3">
<span class="input-group-text">Last Updated</span>
<span class="form-control">
{{ dataset.date.strftime('%d %b %Y %H:%M') }}
</span>
</div>
{% if dataset.default %}
<div class="input-group mb-3">
<span class="input-group-text">
<input type="checkbox" aria-label="Default" class="dataset-default" checked disabled>
</span>
<span class="form-control">
Default Dataset
</select>
</div>
{% endif %}
</div>
<div class="container">
<table id="analysis-table" class="table table-striped" style="width:100%">
<thead>
<th data-priority="1">
Question
</th>
<th data-priority="1">
Percent Correct
</th>
<th data-priority="2">
Answers
</th>
<th data-priority="3">
Tags
</th>
</thead>
<tbody>
{% for question in questions %}
<tr class="table-row">
<td>
{{ question.q_no + 1 }}
</td>
<td class="cell-percentage">
{{ ((analysis.answers[question.q_no][question.correct] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}
</td>
<td>
<table style="width:100%">
{% for option in question.options %}
<tr>
<td style="width:50%">
{{ option[1] }}
</td>
<td>
{% if question.correct == option[0] %}
<div class="progress">
<div class="progress-bar bg-success progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
</div>
{% else %}
<div class="progress">
<div class="progress-bar bg-danger progress-bar-striped" role="progressbar" style="width: {{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}%;" aria-valuenow="{{ (analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum() }}" aria-valuemin="0" aria-valuemax="100">{{ ((analysis.answers[question.q_no][option[0]] or 0)*100/(analysis.answers[question.q_no].values())|sum())|round(2) }}%</div>
</div>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</td>
<td>
<ul>
{% for tag in question.tags %}
<li>{{ tag|safe }}</li>
{% endfor %}
</ul>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block script %}
<script>
const target = "{{ url_for('api._editor') }}"
const id = "{{ dataset.id }}"
</script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/analysis.js') }}"
></script>
{% endblock %}
{% block custom_data_script %}
<script>
console.log($('#analysis-table'))
$(document).ready(function() {
$('#analysis-table').DataTable({
'searching': true,
'columnDefs': [
{'sortable': true, 'targets': [0,1]},
{'sortable': false, 'targets': [2,3]},
{'searchable': true, 'targets': [0,2,3]}
],
'order': [[0, 'asc'], [1, 'desc']],
'buttons': [
{
extend: 'print',
exportOptions: {
columns: [0, 1, 2, 3]
}
},
{
extend: 'excel',
exportOptions: {
columns: [0, 1, 2, 3]
}
},
{
extend: 'pdf',
exportOptions: {
columns: [0, 1, 2, 3]
}
}
],
'responsive': 'true',
'colReorder': 'true',
'fixedHeader': 'true',
'searchBuilder': {
depthLimit: 2,
columns: [2, 3],
},
dom: 'BQlfrtip'
});
// $('.buttons-pdf').html('<span class="glyphicon glyphicon-file" data-toggle="tooltip" title="Export To Excel"/>') -->
} );
$('#analysis-table').show();
$(window).trigger('resize');
</script>
{% endblock %}

View File

@ -0,0 +1,92 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
crossorigin="anonymous">
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css">
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/style.css') }}"
/>
<link
rel="stylesheet"
href="{{ url_for('.static', filename='css/view.css') }}"
/>
{% block style %}
{% endblock %}
<title>{% block title %} SKA Referee Test | Admin Console {% endblock %}</title>
{% include "analysis/components/og-meta.html" %}
</head>
<body class="bg-light">
{% block navbar %}
{% include "analysis/components/navbar.html" %}
{% endblock %}
<div class="container">
{% block top_alerts %}
{% include "analysis/components/server-alerts.html" %}
{% endblock %}
{% block content %}{% endblock %}
</div>
<footer class="container site-footer mt-5">
{% block footer %}
{% include "analysis/components/footer.html" %}
{% endblock %}
</footer>
<!-- JQuery, Popper, and Bootstrap js dependencies -->
<script
src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous">
</script>
<script>
window.jQuery || document.write(`<script src="{{ url_for('.static', filename='js/jquery-3.6.0.min.js') }}"><\/script>`)
</script>
<script
src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.10.2/dist/umd/popper.min.js"
integrity="sha384-7+zCNj/IqJ95wo16oMtfsKbZ9ccEh31eOz1HGyDuCQ6wgnyJNSYdrPa03rtR1zdB"
crossorigin="anonymous">
</script>
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.min.js"
integrity="sha384-QJHtvGhmr9XOIpI6YVutG+2QOK9T+ZnN4kzFN1RtK3zEFEIsxhlmWl5/YESvpZ13"
crossorigin="anonymous"
></script>
<!-- Custom js -->
<script type="text/javascript">
var csrf_token = "{{ csrf_token() }}";
$.ajaxSetup({
beforeSend: function(xhr, settings) {
if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
xhr.setRequestHeader("X-CSRFToken", csrf_token);
}
}
});
</script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/script.js') }}"
></script>
<script
type="text/javascript"
src="{{ url_for('.static', filename='js/analysis.js') }}"
></script>
{% block script %}
{% endblock %}
{% block datatable_scripts %}
{% endblock %}
{% block custom_data_script %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1 @@
<div id="alert-box" tabindex="-1"></div>

View File

@ -0,0 +1,28 @@
{% extends "analysis/components/base.html" %}
{% block datatable_css %}
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.11.3/css/dataTables.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/buttons/2.0.1/css/buttons.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/colreorder/1.5.5/css/colReorder.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/fixedheader/3.2.0/css/fixedHeader.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/keytable/2.6.4/css/keyTable.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/responsive/2.2.9/css/responsive.bootstrap5.min.css"/>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/searchbuilder/1.3.0/css/searchBuilder.dataTables.min.css"/>
{% endblock %}
{% block datatable_scripts %}
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/2.5.0/jszip.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/pdfmake.min.js"></script>
<script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.36/vfs_fonts.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/jquery.dataTables.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/1.11.3/js/dataTables.bootstrap5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/dataTables.buttons.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.bootstrap5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.colVis.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.html5.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/buttons/2.0.1/js/buttons.print.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/colreorder/1.5.5/js/dataTables.colReorder.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/fixedheader/3.2.0/js/dataTables.fixedHeader.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/keytable/2.6.4/js/dataTables.keyTable.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/dataTables.responsive.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/responsive/2.2.9/js/responsive.bootstrap5.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/searchbuilder/1.3.0/js/dataTables.searchBuilder.min.js"></script>
{% endblock %}

View File

@ -0,0 +1,2 @@
<p>This web app was developed and is maintained by Vivek Santayana. The source code for the web app, excluding any data pertaining to the questions in the quiz, is freely available at <a href="https://git.vsnt.uk/viveksantayana/ska-referee-test">Vivek&rsquo;s personal GIT repository</a> under an MIT License.</p>
<p>All questions in the test are &copy; The Scottish Korfball Association {{ now.year }}. All rights are reserved.</p>

View File

@ -0,0 +1,4 @@
{% extends "analysis/components/base.html" %}
{% import "bootstrap/wtf.html" as wtf %}
{% block top_alerts %}
{% endblock %}

View File

@ -0,0 +1,137 @@
<nav class="navbar fixed-top navbar-expand-md navbar-dark bg-dark">
<div class="container">
<a href="{{ url_for('admin._home') }}" class="navbar-brand mb-0 h1">RefTest | Admin</a>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbar"
aria-controls="navbar"
aria-expanded="false"
aria-label="Toggle Navigation"
>
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbar">
<ul class="navbar-nav">
{% if not current_user.is_authenticated %}
<li class="nav-item" id="nav-login">
<a href="{{ url_for('admin._login') }}" id="link-login" class="nav-link">Log In</a>
</li>
{% endif %}
{% if current_user.is_authenticated %}
<li class="nav-item dropdown" id="nav-results">
<a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-tests">
<a
class="nav-link dropdown-toggle"
id="dropdown-tests"
role="button"
href="{{ url_for('admin._tests') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Exams
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-tests"
>
<li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='scheduled') }}" id="link-scheduled" class="dropdown-item">Scheduled</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='expired') }}" id="link-expired" class="dropdown-item">Expired</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='all') }}" id="link-all" class="dropdown-item">All</a>
</li>
<li>
<a href="{{ url_for('admin._tests', filter='create') }}" id="link-create" class="dropdown-item">Create</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-settings">
<a
class="nav-link dropdown-toggle"
id="dropdown-settings"
role="button"
href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Settings
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-settings"
>
<li>
<a href="{{ url_for('admin._settings') }}" id="link-settings" class="dropdown-item">View Settings</a>
</li>
<li>
<a href="{{ url_for('admin._users') }}" id="link-users" class="dropdown-item">Users</a>
</li>
<li>
<a href="{{ url_for('admin._questions') }}" id="link-questions" class="dropdown-item">Manage Questions</a>
</li>
<li>
<a href="{{ url_for('view._view') }}" id="link-editor" class="dropdown-item">View Questions</a>
</li>
<li>
<a href="{{ url_for('editor._editor') }}" id="link-editor" class="dropdown-item">Edit Questions</a>
</li>
</ul>
</li>
<li class="nav-item dropdown" id="nav-account">
<a
class="nav-link dropdown-toggle"
id="dropdown-account"
role="button"
href="{{ url_for('admin._update_user', id=current_user.id) }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Account
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-account"
>
<li>
<a href="{{ url_for('admin._update_user', id=current_user.id) }}" id="link-account" class="dropdown-item">Account Settings</a>
</li>
<li>
<a href="{{ url_for('admin._logout') }}" id="link-logout" class="dropdown-item">Log Out</a>
</li>
</ul>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>

View File

@ -0,0 +1,18 @@
<meta name="description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:locale" content="en_UK" />
<meta property="og:type" content="website" />
<meta property="og:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta property="og:url" content="{{ url_for(request.endpoint, _external = True, **(request.view_args or {})) }}" />
<meta property="og:site_name" content="Scottish Korfball Association Referee Theory Exam" />
<meta property="og:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
<meta property="og:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:description" content="A web app for taking the Scottish Korfball Association Refereeing Theory Exam on-line." />
<meta name="twitter:image" content="{{ url_for('.static', filename='favicon.png', _external = True) }}" />
<meta name="twitter:image:alt" content="Logo of the SKA Refereeing Exam App" />
<meta name="twitter:creator" content="@viveksantayana" />
<meta name="twitter:site" content="@viveksantayana" />
<meta name="theme-color" content="#343a40" />
<link rel="shortcut icon" href="{{ url_for('.static', filename='favicon.ico') }}">

View File

@ -0,0 +1,23 @@
<div class="navbar navbar-expand-sm navbar-light bg-light">
<div class="container-fluid">
<div class="expand navbar-expand justify-content-center" id="navbar_secondary">
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='active') }}">Active</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='scheduled') }}">Scheduled</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='expired') }}">Expired</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='all') }}">All</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin._tests', filter='create') }}">Create</a>
</li>
</ul>
</div>
</div>
</div>

View File

@ -0,0 +1,43 @@
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% set cookie_flash_flag = namespace(value=False) %}
{% for category, message in messages %}
{% if category == "error" %}
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="bi bi-exclamation-triangle-fill" title="Error" aria-title="Error"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "success" %}
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="bi bi-check2-circle" title="Success" aria-title="Success"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "warning" %}
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" aria-title="Warning" title="Warning"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% elif category == "cookie_alert" %}
{% if not cookie_flash_flag.value %}
<div class="alert alert-primary alert-dismissible fade show" id="cookie-alert" role="alert">
<i class="bi bi-info-circle-fill" title="Cookie Alert" aria-title="Cookie Alert"></i>
{{ message|safe }}
<div class="d-flex justify-content-center w-100">
<button type="button" id="dismiss-cookie-alert" class="btn btn-success" data-bs-dismiss="alert" aria-label="Close">Accept</button>
</div>
</div>
{% set cookie_flash_flag.value = True %}
{% endif %}
{% else %}
<div class="alert alert-primary alert-dismissible fade show" role="alert">
<i class="bi bi-info-circle-fill" title="Alert"></i>
{{ message|safe }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endwith %}

View File

@ -0,0 +1,54 @@
{% extends "analysis/components/input-forms.html" %}
{% block content %}
<h1>Analysis</h1>
<div class="container">
<div class="row">
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Exams</h5>
<div class="card-text">
<div class="form-select-input">
<select name="select-test" id="select-test">
{% for test in tests %}
<option value="{{ test.id }}">{{ test.get_code() }}</option>
{% endfor %}
</select>
</div>
<div class="my-3">
<a href="{{ url_for('analysis._test') }}" class="btn btn-primary button-analyse" data-class="test">
<i class="bi bi-search button-icon"></i>
Analyse
</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm">
<div class="card m-3">
<div class="card-body">
<h5 class="card-title">Datasets</h5>
<div class="card-text">
<div class="form-select-input">
<select name="select-dataset" id="select-dataset">
{% for dataset in datasets %}
<option value="{{ dataset.id }}">{{ dataset.get_name() }}</option>
{% endfor %}
</select>
</div>
<div class="my-3">
<a href="{{ url_for('analysis._dataset') }}" class="btn btn-primary button-analyse" data-class="dataset">
<i class="bi bi-search button-icon"></i>
Analyse
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% include "analysis/components/client-alerts.html" %}
{% endblock %}

View File

@ -0,0 +1,85 @@
from ..models import Dataset, Test
from ..tools.data import analyse, check_dataset_exists, check_test_exists
from ..tools.logs import write
from ..tools.data import parse_questions
from flask import Blueprint, render_template, request, jsonify
from flask.helpers import abort, flash, redirect, url_for
from flask_login import login_required
analysis = Blueprint(
name='analysis',
import_name=__name__,
template_folder='templates',
static_folder='static'
)
@analysis.route('/', methods=['GET','POST'])
@login_required
@check_dataset_exists
@check_test_exists
def _analysis():
try:
_tests = Test.query.all()
_datasets = Dataset.query.all()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
tests = [ test for test in _tests if test.entries ]
datasets = [ dataset for dataset in _datasets if dataset.entries ]
if request.method == 'POST':
selection = request.get_json()
if selection['class'] == 'test':
try:
test = Test.query.filter_by(id=selection['id']).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not test: return jsonify({'error': 'Invalid entry ID.'}), 404
return url_for('analysis._test', id=selection['id']), 200
if selection['class'] == 'dataset':
try:
dataset = Dataset.query.filter_by(id=selection['id']).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not dataset: return jsonify({'error': 'Invalid entry ID.'}), 404
return url_for('analysis._dataset', id=selection['id']), 200
return jsonify({'error': 'Invalid entry ID.'}), 404
return render_template('/analysis/index.html', tests=tests, datasets=datasets)
@analysis.route('/test/<string:id>')
@analysis.route('/test/')
@login_required
@check_test_exists
def _test(id:str=None):
if id in [None, '']:
flash(message='Please select a valid exam.', category='error')
return redirect(url_for('analysis._analysis'))
try:
test = Test.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not test:
flash('Invalid exam.', 'error')
return redirect(url_for('analysis._analysis'))
return render_template('/analysis/analysis.html', analysis=analyse(test), subject=test.get_code(), type='exam', dataset=test.dataset, questions=parse_questions(test.dataset.get_data()))
@analysis.route('/dataset/<string:id>')
@analysis.route('/dataset/')
@login_required
@check_dataset_exists
def _dataset(id:str=None):
if id in [None, '']:
flash(message='Please select a valid dataset.', category='error')
return redirect(url_for('analysis._analysis'))
try:
dataset = Dataset.query.filter_by(id=id).first()
except Exception as exception:
write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500)
if not dataset:
flash('Invalid dataset.', 'error')
return redirect(url_for('analysis._analysis'))
return render_template('/analysis/analysis.html', analysis=analyse(dataset), subject=dataset.get_name(), type='dataset', dataset=dataset, questions=parse_questions(dataset.get_data()))

View File

@ -20,8 +20,28 @@
</li> </li>
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item" id="nav-results"> <li class="nav-item dropdown" id="nav-results">
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a> <a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li> </li>
<li class="nav-item dropdown" id="nav-tests"> <li class="nav-item dropdown" id="nav-tests">
<a <a
@ -36,7 +56,7 @@
</a> </a>
<ul <ul
class="dropdown-menu" class="dropdown-menu"
aria-labelledby="dropdown-settings" aria-labelledby="dropdown-tests"
> >
<li> <li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a> <a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
@ -58,7 +78,7 @@
<li class="nav-item dropdown" id="nav-settings"> <li class="nav-item dropdown" id="nav-settings">
<a <a
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
id="dropdown-account" id="dropdown-settings"
role="button" role="button"
href="{{ url_for('admin._settings') }}" href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"

View File

@ -8,7 +8,7 @@ from flask_login import current_user
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from datetime import datetime from datetime import datetime
from json import dump from json import dump, loads
from os import path, remove from os import path, remove
from pathlib import Path from pathlib import Path
from uuid import uuid4 from uuid import uuid4
@ -17,6 +17,7 @@ class Dataset(db.Model):
id = db.Column(db.String(36), index=True, primary_key=True) id = db.Column(db.String(36), index=True, primary_key=True)
name = db.Column(db.String(128), nullable=False) name = db.Column(db.String(128), nullable=False)
tests = db.relationship('Test', backref='dataset') tests = db.relationship('Test', backref='dataset')
entries = db.relationship('Entry', backref='dataset')
creator_id = db.Column(db.String(36), db.ForeignKey('user.id')) creator_id = db.Column(db.String(36), db.ForeignKey('user.id'))
date = db.Column(db.DateTime, nullable=False) date = db.Column(db.DateTime, nullable=False)
default = db.Column(db.Boolean, default=False, nullable=True) default = db.Column(db.Boolean, default=False, nullable=True)
@ -115,6 +116,12 @@ class Dataset(db.Model):
file_path = path.join(data, 'questions', filename) file_path = path.join(data, 'questions', filename)
return file_path return file_path
def get_data(self):
dataset_path = self.get_file()
with open(dataset_path, 'r') as _dataset:
data = loads(_dataset.read())
return data
def update(self, data:list=None, default:bool=False): def update(self, data:list=None, default:bool=False):
self.date = datetime.now() self.date = datetime.now()
if default: self.make_default() if default: self.make_default()

View File

@ -2,6 +2,7 @@ from ..extensions import db, mail
from ..tools.encryption import decrypt, encrypt from ..tools.encryption import decrypt, encrypt
from ..tools.logs import write from ..tools.logs import write
from .test import Test from .test import Test
from .dataset import Dataset
from flask_login import current_user from flask_login import current_user
from flask_mail import Message from flask_mail import Message
@ -17,6 +18,7 @@ class Entry(db.Model):
email = db.Column(db.String(128), nullable=False) email = db.Column(db.String(128), nullable=False)
club = db.Column(db.String(128), nullable=True) club = db.Column(db.String(128), nullable=True)
test_id = db.Column(db.String(36), db.ForeignKey('test.id')) test_id = db.Column(db.String(36), db.ForeignKey('test.id'))
dataset_id = db.Column(db.String(36), db.ForeignKey('dataset.id'))
user_code = db.Column(db.String(6), nullable=True) user_code = db.Column(db.String(6), nullable=True)
start_time = db.Column(db.DateTime, index=True, nullable=True) start_time = db.Column(db.DateTime, index=True, nullable=True)
end_time = db.Column(db.DateTime, index=True, nullable=True) end_time = db.Column(db.DateTime, index=True, nullable=True)

View File

@ -60,6 +60,7 @@ def _start():
write('system.log', f'Database error when processing request \'{request.url}\': {exception}') write('system.log', f'Database error when processing request \'{request.url}\': {exception}')
return abort(500) return abort(500)
entry.test = test entry.test = test
entry.dataset = test.dataset
entry.user_code = request.form.get('user_code') entry.user_code = request.form.get('user_code')
entry.user_code = None if entry.user_code == '' else entry.user_code.lower() entry.user_code = None if entry.user_code == '' else entry.user_code.lower()
if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400 if not test: return jsonify({'error': 'The exam code you entered is invalid.'}), 400

View File

@ -1,4 +1,4 @@
from ..models import Dataset from ..models import Dataset, Test
from ..tools.logs import write from ..tools.logs import write
from flask import current_app as app from flask import current_app as app
@ -7,6 +7,8 @@ from flask.helpers import abort, flash, redirect, url_for
import json import json
from pathlib import Path from pathlib import Path
from random import shuffle from random import shuffle
from statistics import mean, median, stdev
from typing import Union
from functools import wraps from functools import wraps
def load(filename:str): def load(filename:str):
@ -85,3 +87,82 @@ def check_dataset_exists(function):
return redirect(url_for('admin._questions')) return redirect(url_for('admin._questions'))
return function(*args, **kwargs) return function(*args, **kwargs)
return wrapper return wrapper
def check_test_exists(function):
@wraps(function)
def wrapper(*args, **kwargs):
try: tests = Test.query.all()
except Exception as exception:
write('system.log', f'Database error when checking existing datasets: {exception}')
return abort(500)
if not tests:
flash('There are no exams configured. Please create an exam first.', 'error')
return redirect(url_for('admin._tests'))
return function(*args, **kwargs)
return wrapper
def analyse(subject:Union[Dataset,Test]) -> dict:
output = {
'answers': {},
'entries': 0,
'grades': {
'merit': 0,
'pass': 0,
'fail': 0
},
'scores': {
'mean': 0,
'median': 0,
'stdev': 0
}
}
scores_raw = []
if isinstance(subject, Test):
for entry in subject.entries:
if entry.answers:
for question, answer in entry.answers.items():
if int(question) not in output['answers']: output['answers'][int(question)] = {}
if int(answer) not in output['answers'][int(question)]: output['answers'][int(question)][int(answer)] = 0
output['answers'][int(question)][int(answer)] += 1
if entry.result:
output['entries'] += 1
output['grades'][entry.result['grade']] += 1
scores_raw.append(int(entry.result['score']))
else:
for test in subject.tests:
for entry in test.entries:
if entry.answers:
for question, answer in entry.answers.items():
if int(question) not in output['answers']: output['answers'][int(question)] = {}
if int(answer) not in output['answers'][int(question)]: output['answers'][int(question)][int(answer)] = 0
output['answers'][int(question)][int(answer)] += 1
if entry.result:
output['entries'] += 1
output['grades'][entry.result['grade']] += 1
scores_raw.append(entry.result['score'])
output['scores']['mean'] = mean(scores_raw)
output['scores']['median'] = median(scores_raw)
output['scores']['stdev'] = stdev(scores_raw, output['scores']['mean']) if len(scores_raw) > 1 else None
return output
def parse_questions(dataset:list):
output = []
for block in dataset:
if block['type'] == 'question':
question = {
'q_no': block['q_no'],
'tags': block['tags'],
'correct': block['correct']
}
question['options'] = [*enumerate(block['options'])]
output.append(question)
elif block['type'] == 'block':
for _question in block['questions']:
question = {
'q_no': _question['q_no'],
'tags': _question['tags'],
'correct': _question['correct']
}
question['options'] = [*enumerate(_question['options'])]
output.append(question)
return output

View File

@ -10,9 +10,10 @@ from functools import wraps
def parse_test_code(code): def parse_test_code(code):
return code.replace('', '').lower() return code.replace('', '').lower()
def generate_questions(dataset:list): def generate_questions(dataset:list, randomise:bool=True):
output = [] output = []
for block in randomise_list(dataset): question_dataset = randomise_list(dataset) if randomise else dataset
for block in question_dataset:
if block['type'] == 'question': if block['type'] == 'question':
question = { question = {
'type': 'question', 'type': 'question',
@ -20,11 +21,12 @@ def generate_questions(dataset:list):
'question_header': '', 'question_header': '',
'text': block['text'] 'text': block['text']
} }
if block['q_type'] == 'Multiple Choice': question['options'] = randomise_list([*enumerate(block['options'])]) if block['q_type'] == 'Multiple Choice' and randomise: question['options'] = randomise_list([*enumerate(block['options'])])
else: question['options'] = [*enumerate(block['options'])] else: question['options'] = [*enumerate(block['options'])]
output.append(question) output.append(question)
elif block['type'] == 'block': elif block['type'] == 'block':
for key, _question in enumerate(randomise_list(block['questions'])): block_questions = randomise_list(block['questions']) if randomise else block['questions']
for key, _question in enumerate(block_questions):
question = { question = {
'type': 'block', 'type': 'block',
'q_no': _question['q_no'], 'q_no': _question['q_no'],
@ -33,7 +35,7 @@ def generate_questions(dataset:list):
'block_q_no': key, 'block_q_no': key,
'text': _question['text'] 'text': _question['text']
} }
if _question['q_type'] == 'Multiple Choice': question['options'] = randomise_list([*enumerate(_question['options'])]) if _question['q_type'] == 'Multiple Choice' and randomise: question['options'] = randomise_list([*enumerate(_question['options'])])
else: question['options'] = [*enumerate(_question['options'])] else: question['options'] = [*enumerate(_question['options'])]
output.append(question) output.append(question)
return output return output

View File

@ -110,6 +110,27 @@ function parse_data(data) {
} }
} }
// Analyse Button
$('.dataset-analyse').click(function(event) {
let id = $(this).data('id')
$.ajax({
url: `/admin/analysis/`,
type: 'POST',
data: JSON.stringify({'id': id, 'class': 'dataset'}),
contentType: 'application/json',
success: function(response) {
window.location.href = response
},
error: function(response){
error_response(response)
},
})
event.preventDefault()
})
// Fetch data once page finishes loading // Fetch data once page finishes loading
$(window).on('load', function() { $(window).on('load', function() {
$.ajax({ $.ajax({

View File

@ -20,8 +20,28 @@
</li> </li>
{% endif %} {% endif %}
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}
<li class="nav-item" id="nav-results"> <li class="nav-item dropdown" id="nav-results">
<a href="{{ url_for('admin._view_entries') }}" id="link-results" class="nav-link">View Results</a> <a
class="nav-link dropdown-toggle"
id="dropdown-results"
role="button"
href="{{ url_for('admin._view_entries') }}"
data-bs-toggle="dropdown"
aria-expanded="false"
>
Results
</a>
<ul
class="dropdown-menu"
aria-labelledby="dropdown-results"
>
<li>
<a href="{{ url_for('admin._view_entries', filter='active') }}" id="link-results" class="dropdown-item">View Results</a>
</li>
<li>
<a href="{{ url_for('analysis._analysis') }}" id="link-analysis" class="dropdown-item">Analysis</a>
</li>
</ul>
</li> </li>
<li class="nav-item dropdown" id="nav-tests"> <li class="nav-item dropdown" id="nav-tests">
<a <a
@ -36,7 +56,7 @@
</a> </a>
<ul <ul
class="dropdown-menu" class="dropdown-menu"
aria-labelledby="dropdown-settings" aria-labelledby="dropdown-tests"
> >
<li> <li>
<a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a> <a href="{{ url_for('admin._tests', filter='active') }}" id="link-active" class="dropdown-item">Active</a>
@ -58,7 +78,7 @@
<li class="nav-item dropdown" id="nav-settings"> <li class="nav-item dropdown" id="nav-settings">
<a <a
class="nav-link dropdown-toggle" class="nav-link dropdown-toggle"
id="dropdown-account" id="dropdown-settings"
role="button" role="button"
href="{{ url_for('admin._settings') }}" href="{{ url_for('admin._settings') }}"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"

View File

@ -100,7 +100,18 @@
</div> </div>
{% endif %} {% endif %}
</div> </div>
<div class="d-flex justify-content-center">
<a
href="#"
class="btn btn-success dataset-analyse {% if not dataset.entries %} disabled {% endif %}"
data-id="{{dataset.id}}"
title="Analyse Answers"
data-action="analyse"
>
<i class="bi bi-search button-icon"></i>
Analyse Answers
</a>
</div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -43,4 +43,4 @@ def _view_console(id:str=None):
if not dataset: if not dataset:
flash('Invalid dataset ID.', 'error') flash('Invalid dataset ID.', 'error')
return redirect(url_for('admin._questions')) return redirect(url_for('admin._questions'))
return render_template('/view/console.html', dataset=dataset, datasets=datasets, users=users) return render_template('/view/console.html', dataset=dataset)

View File

@ -1,32 +1,33 @@
blinker==1.5 blinker==1.5
cffi==1.15.1 cffi==1.15.1
click==8.1.3 click==8.1.3
cryptography==38.0.1 cryptography==39.0.2
dnspython==2.2.1 dnspython==2.3.0
dominate==2.7.0 dominate==2.7.0
email-validator==1.2.1 email-validator==1.3.1
Flask==2.2.2 Flask==2.2.3
Flask-Bootstrap==3.3.7.1 Flask-Bootstrap==3.3.7.1
Flask-Login==0.6.2 Flask-Login==0.6.2
Flask-Mail==0.9.1 Flask-Mail==0.9.1
Flask-SQLAlchemy==2.5.1 Flask-SQLAlchemy==3.0.3
Flask-WTF==1.0.1 Flask-WTF==1.1.1
greenlet==1.1.3 greenlet==2.0.2
gunicorn==20.1.0 gunicorn==20.1.0
idna==3.3 idna==3.4
itsdangerous==2.1.2 itsdangerous==2.1.2
Jinja2==3.1.2 Jinja2==3.1.2
MarkupSafe==2.1.1 MarkupSafe==2.1.2
pip==22.2.2 pip==23.0.1
pycparser==2.21 pycparser==2.21
PyMySQL==1.0.2 PyMySQL==1.0.2
python-dotenv==0.21.0 python-dotenv==1.0.0
setuptools==65.3.0 setuptools==67.4.0
six==1.16.0 six==1.16.0
SQLAlchemy==1.4.41 SQLAlchemy==2.0.4
sqlalchemy-json==0.5.0 sqlalchemy-json==0.5.0
SQLAlchemy-Utils==0.38.3 SQLAlchemy-Utils==0.40.0
typing_extensions==4.5.0
visitor==0.1.3 visitor==0.1.3
Werkzeug==2.2.2 Werkzeug==2.2.3
wheel==0.37.1 wheel==0.38.4
WTForms==3.0.1 WTForms==3.0.1