update
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,3 +6,7 @@ public
|
|||||||
wip/output
|
wip/output
|
||||||
content/.obsidian/workspace.json
|
content/.obsidian/workspace.json
|
||||||
content/.obsidian/plugins/text-extractor/cache
|
content/.obsidian/plugins/text-extractor/cache
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-shm
|
||||||
|
*.sqlite3-wal
|
||||||
|
*.pyc
|
||||||
|
|||||||
BIN
quiz/db.sqlite3
BIN
quiz/db.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
29
quiz/quiz/forms.py
Normal file
29
quiz/quiz/forms.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.db.models import Count
|
||||||
|
from .models import Course, Tag
|
||||||
|
|
||||||
|
class TagModelMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||||
|
def label_from_instance(self, obj):
|
||||||
|
return f"{obj.name} ({obj.question_count})"
|
||||||
|
|
||||||
|
class CreateQuizForm(forms.Form):
|
||||||
|
course = forms.ModelChoiceField(
|
||||||
|
queryset=Course.objects.all(),
|
||||||
|
required=False,
|
||||||
|
empty_label="All Courses",
|
||||||
|
widget=forms.Select(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
tags = TagModelMultipleChoiceField(
|
||||||
|
queryset=Tag.objects.annotate(question_count=Count('questions')).order_by('name'),
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
|
QUESTION_TYPES = [
|
||||||
|
('single', 'Single Choice'),
|
||||||
|
('multi', 'Multiple Choice'),
|
||||||
|
]
|
||||||
|
question_type = forms.MultipleChoiceField(
|
||||||
|
choices=QUESTION_TYPES,
|
||||||
|
required=False,
|
||||||
|
widget=forms.SelectMultiple(attrs={'class': 'form-control'})
|
||||||
|
)
|
||||||
Binary file not shown.
@@ -13,6 +13,11 @@ class Command(BaseCommand):
|
|||||||
default='content/Anatomi & Histologi 2/Gamla tentor',
|
default='content/Anatomi & Histologi 2/Gamla tentor',
|
||||||
help='Folder to import questions from (relative to project root)'
|
help='Folder to import questions from (relative to project root)'
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--force',
|
||||||
|
action='store_true',
|
||||||
|
help='Force import even if files have not changed'
|
||||||
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
import_folder = options['folder']
|
import_folder = options['folder']
|
||||||
@@ -24,7 +29,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
self.stdout.write(self.style.SUCCESS(f'Importing questions from {folder}...'))
|
self.stdout.write(self.style.SUCCESS(f'Importing questions from {folder}...'))
|
||||||
|
|
||||||
stats = import_questions(folder, folder)
|
stats = import_questions(folder, folder, force=options['force'])
|
||||||
|
|
||||||
# Only show full statistics if there were changes
|
# Only show full statistics if there were changes
|
||||||
output = stats.format_output(show_if_no_changes=False)
|
output = stats.format_output(show_if_no_changes=False)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import index, get_next_question, submit_answer, stats
|
from .views import index, get_next_question, submit_answer, stats, create_quiz
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
@@ -9,5 +9,6 @@ urlpatterns = [
|
|||||||
path('next/', get_next_question, name='next_question'),
|
path('next/', get_next_question, name='next_question'),
|
||||||
path('submit/', submit_answer, name='submit_answer'),
|
path('submit/', submit_answer, name='submit_answer'),
|
||||||
path('stats/', stats, name='stats'),
|
path('stats/', stats, name='stats'),
|
||||||
|
path('create/', create_quiz, name='create_quiz'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -92,27 +92,35 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
|
|||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if line.strip() == '---':
|
if line.strip() == '---':
|
||||||
in_frontmatter = not in_frontmatter
|
if in_frontmatter:
|
||||||
|
# End of frontmatter
|
||||||
|
in_frontmatter = False
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
in_frontmatter = True
|
||||||
continue
|
continue
|
||||||
if in_frontmatter and 'frågetyp/' in line:
|
|
||||||
is_question = True
|
if in_frontmatter:
|
||||||
# Extract question type
|
if 'frågetyp/' in line:
|
||||||
if 'frågetyp/mcq' in line:
|
is_question = True
|
||||||
question_type = 'mcq'
|
# Extract question type
|
||||||
elif 'frågetyp/scq' in line:
|
if 'frågetyp/mcq' in line:
|
||||||
question_type = 'scq'
|
question_type = 'mcq'
|
||||||
elif 'frågetyp/textalternativ' in line:
|
elif 'frågetyp/scq' in line:
|
||||||
question_type = 'textalternativ'
|
question_type = 'scq'
|
||||||
elif 'frågetyp/textfält' in line:
|
elif 'frågetyp/textalternativ' in line:
|
||||||
question_type = 'textfält'
|
question_type = 'textalternativ'
|
||||||
elif in_frontmatter and line.strip().lower().startswith('tags:'):
|
elif 'frågetyp/textfält' in line:
|
||||||
# Extract tags
|
question_type = 'textfält'
|
||||||
# Handle: tags: [tag1, tag2] or tags: tag1, tag2
|
|
||||||
tag_content = line.split(':', 1)[1].strip()
|
if line.strip().lower().startswith('tags:'):
|
||||||
# Remove brackets if present
|
# Extract tags
|
||||||
tag_content = tag_content.strip('[]')
|
# Handle: tags: [tag1, tag2] or tags: tag1, tag2
|
||||||
# Split by comma
|
tag_content = line.split(':', 1)[1].strip()
|
||||||
tags = [t.strip() for t in tag_content.split(',') if t.strip()]
|
# Remove brackets if present
|
||||||
|
tag_content = tag_content.strip('[]')
|
||||||
|
# Split by comma
|
||||||
|
tags = [t.strip() for t in tag_content.split(',') if t.strip()]
|
||||||
|
|
||||||
|
|
||||||
if not is_question:
|
if not is_question:
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
from django.urls import reverse
|
||||||
from django.views.decorators.http import require_http_methods
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
from .models import Question, QuizResult, Tag
|
from .models import Question, QuizResult, Tag
|
||||||
|
from .forms import CreateQuizForm
|
||||||
|
|
||||||
|
|
||||||
def handle_tag_filter(request):
|
def handle_tag_filter(request):
|
||||||
@@ -14,6 +17,35 @@ def handle_tag_filter(request):
|
|||||||
else:
|
else:
|
||||||
request.session['quiz_tag'] = tag_slug
|
request.session['quiz_tag'] = tag_slug
|
||||||
|
|
||||||
|
def create_quiz(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = CreateQuizForm(request.POST)
|
||||||
|
if form.is_valid():
|
||||||
|
# clear existing session data
|
||||||
|
keys_to_clear = ['quiz_filter_course_id', 'quiz_filter_tag_ids', 'quiz_filter_types', 'quiz_tag']
|
||||||
|
for key in keys_to_clear:
|
||||||
|
if key in request.session:
|
||||||
|
del request.session[key]
|
||||||
|
|
||||||
|
course = form.cleaned_data.get('course')
|
||||||
|
tags = form.cleaned_data.get('tags')
|
||||||
|
question_types = form.cleaned_data.get('question_type')
|
||||||
|
|
||||||
|
if course:
|
||||||
|
request.session['quiz_filter_course_id'] = course.id
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
request.session['quiz_filter_tag_ids'] = list(tags.values_list('id', flat=True))
|
||||||
|
|
||||||
|
if question_types:
|
||||||
|
request.session['quiz_filter_types'] = question_types
|
||||||
|
|
||||||
|
return redirect('next_question')
|
||||||
|
else:
|
||||||
|
form = CreateQuizForm()
|
||||||
|
|
||||||
|
return render(request, 'quiz_create.html', {'form': form})
|
||||||
|
|
||||||
def index(request):
|
def index(request):
|
||||||
handle_tag_filter(request)
|
handle_tag_filter(request)
|
||||||
total_questions = Question.objects.count()
|
total_questions = Question.objects.count()
|
||||||
@@ -34,13 +66,40 @@ def get_next_question(request):
|
|||||||
|
|
||||||
current_tag = request.session.get('quiz_tag')
|
current_tag = request.session.get('quiz_tag')
|
||||||
|
|
||||||
|
# New filters
|
||||||
|
filter_course_id = request.session.get('quiz_filter_course_id')
|
||||||
|
filter_tag_ids = request.session.get('quiz_filter_tag_ids')
|
||||||
|
filter_types = request.session.get('quiz_filter_types')
|
||||||
|
|
||||||
answered_ids = QuizResult.objects.filter(user=request.quiz_user).values_list('question_id', flat=True)
|
answered_ids = QuizResult.objects.filter(user=request.quiz_user).values_list('question_id', flat=True)
|
||||||
|
|
||||||
questions = Question.objects.exclude(id__in=answered_ids)
|
questions = Question.objects.exclude(id__in=answered_ids)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
if current_tag:
|
if current_tag:
|
||||||
questions = questions.filter(tags__slug=current_tag)
|
questions = questions.filter(tags__slug=current_tag)
|
||||||
|
|
||||||
|
if filter_course_id:
|
||||||
|
questions = questions.filter(exam__course_id=filter_course_id)
|
||||||
|
|
||||||
|
if filter_tag_ids:
|
||||||
|
questions = questions.filter(tags__id__in=filter_tag_ids)
|
||||||
|
|
||||||
|
if filter_types:
|
||||||
|
# "single" -> no comma
|
||||||
|
# "multi" -> comma
|
||||||
|
q_objs = Q()
|
||||||
|
if 'single' in filter_types:
|
||||||
|
q_objs |= ~Q(correct_answer__contains=',')
|
||||||
|
if 'multi' in filter_types:
|
||||||
|
q_objs |= Q(correct_answer__contains=',')
|
||||||
|
|
||||||
|
if q_objs:
|
||||||
|
questions = questions.filter(q_objs)
|
||||||
|
|
||||||
|
# Distinguish questions based on filters to ensure we don't get duplicates if filtering by many-to-many
|
||||||
|
questions = questions.distinct()
|
||||||
|
|
||||||
next_question = questions.first()
|
next_question = questions.first()
|
||||||
|
|
||||||
if not next_question:
|
if not next_question:
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
.filter-section { margin-bottom: 20px; }
|
.filter-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-chip {
|
.tag-chip {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
@@ -14,13 +17,16 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-chip.active {
|
.tag-chip.active {
|
||||||
background: #4CAF50;
|
background: #4CAF50;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-chip:hover {
|
.tag-chip:hover {
|
||||||
background: #d5d5d5;
|
background: #d5d5d5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-chip.active:hover {
|
.tag-chip.active:hover {
|
||||||
background: #45a049;
|
background: #45a049;
|
||||||
}
|
}
|
||||||
@@ -29,18 +35,19 @@
|
|||||||
<h1>Quiz Application</h1>
|
<h1>Quiz Application</h1>
|
||||||
|
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
|
<a href="{% url 'create_quiz' %}" class="tag-chip" style="background: #2196F3; color: white;">+ New Quiz</a>
|
||||||
<a href="?tag=" class="tag-chip {% if not current_tag %}active{% endif %}">All</a>
|
<a href="?tag=" class="tag-chip {% if not current_tag %}active{% endif %}">All</a>
|
||||||
{% for tag in tags %}
|
{% for tag in tags %}
|
||||||
<a href="?tag={{ tag.slug }}" class="tag-chip {% if current_tag == tag.slug %}active{% endif %}">
|
<a href="?tag={{ tag.slug }}" class="tag-chip {% if current_tag == tag.slug %}active{% endif %}">
|
||||||
{{ tag.name }}
|
{{ tag.name }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="progress">
|
<div class="progress">
|
||||||
<div class="progress-bar" style="width: {% if total_questions > 0 %}{{ answered_count|floatformat:0 }}{% else %}0{% endif %}%"></div>
|
<div class="progress-bar"
|
||||||
|
style="width: {% if total_questions > 0 %}{{ answered_count|floatformat:0 }}{% else %}0{% endif %}%"></div>
|
||||||
</div>
|
</div>
|
||||||
<p>Besvarade frågor: {{ answered_count }} / {{ total_questions }}</p>
|
<p>Besvarade frågor: {{ answered_count }} / {{ total_questions }}</p>
|
||||||
<div id="quiz-container" hx-get="{% url 'next_question' %}" hx-trigger="load"></div>
|
<div id="quiz-container" hx-get="{% url 'next_question' %}" hx-trigger="load"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
64
quiz/templates/quiz_create.html
Normal file
64
quiz/templates/quiz_create.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<style>
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
background: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background: #45a049;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<h1>Create New Quiz</h1>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.course.id_for_label }}">Course:</label>
|
||||||
|
{{ form.course }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.tags.id_for_label }}">Tags:</label>
|
||||||
|
{{ form.tags }}
|
||||||
|
<small style="color: grey; display: block; margin-top: 5px;">Hold Ctrl (or Cmd) to select multiple tags.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.question_type.id_for_label }}">Question Type:</label>
|
||||||
|
{{ form.question_type }}
|
||||||
|
<small style="color: grey; display: block; margin-top: 5px;">Hold Ctrl (or Cmd) to select multiple
|
||||||
|
types.</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn">Start Quiz</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p><a href="{% url 'index' %}">Back to Dashboard</a></p>
|
||||||
|
{% endblock %}
|
||||||
80
quiz/tests/test_quiz_creation.py
Normal file
80
quiz/tests/test_quiz_creation.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from django.test import TestCase, Client
|
||||||
|
from django.urls import reverse
|
||||||
|
from quiz.models import Course, Tag, Question, Exam
|
||||||
|
from quiz.forms import CreateQuizForm
|
||||||
|
|
||||||
|
class QuizCreationTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.course1 = Course.objects.create(name="Course 1", code="C1")
|
||||||
|
self.course2 = Course.objects.create(name="Course 2", code="C2")
|
||||||
|
|
||||||
|
self.tag1 = Tag.objects.create(name="Tag 1", slug="tag-1")
|
||||||
|
self.tag2 = Tag.objects.create(name="Tag 2", slug="tag-2")
|
||||||
|
|
||||||
|
self.exam1 = Exam.objects.create(course=self.course1, date="2023-01-01")
|
||||||
|
self.exam2 = Exam.objects.create(course=self.course2, date="2023-01-02")
|
||||||
|
|
||||||
|
self.q1 = Question.objects.create(exam=self.exam1, text="Q1", correct_answer="A", file_path="p1")
|
||||||
|
self.q1.tags.add(self.tag1)
|
||||||
|
|
||||||
|
self.q2 = Question.objects.create(exam=self.exam2, text="Q2", correct_answer="A,B", file_path="p2")
|
||||||
|
self.q2.tags.add(self.tag2)
|
||||||
|
|
||||||
|
self.client = Client()
|
||||||
|
|
||||||
|
def test_create_quiz_form_valid(self):
|
||||||
|
form_data = {
|
||||||
|
'course': self.course1.id,
|
||||||
|
'tags': [self.tag1.id],
|
||||||
|
'question_type': ['single']
|
||||||
|
}
|
||||||
|
form = CreateQuizForm(data=form_data)
|
||||||
|
self.assertTrue(form.is_valid())
|
||||||
|
|
||||||
|
def test_create_quiz_view_post_updates_session(self):
|
||||||
|
response = self.client.post(reverse('create_quiz'), {
|
||||||
|
'course': self.course1.id,
|
||||||
|
'tags': [self.tag1.id],
|
||||||
|
'question_type': ['single']
|
||||||
|
})
|
||||||
|
self.assertRedirects(response, reverse('next_question'))
|
||||||
|
|
||||||
|
session = self.client.session
|
||||||
|
self.assertEqual(session['quiz_filter_course_id'], self.course1.id)
|
||||||
|
self.assertEqual(session['quiz_filter_tag_ids'], [self.tag1.id])
|
||||||
|
self.assertEqual(session['quiz_filter_types'], ['single'])
|
||||||
|
|
||||||
|
def test_get_next_question_respects_filters(self):
|
||||||
|
# Set session data manually
|
||||||
|
session = self.client.session
|
||||||
|
session['quiz_filter_course_id'] = self.course1.id
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
# Should get Q1 (Course 1) but not Q2 (Course 2)
|
||||||
|
# Note: We need a user session for answered questions tracking,
|
||||||
|
# but the view handles anonymous users by creating a session or erroring?
|
||||||
|
# Let's check middleware. It seems middleware handles quiz_user creation.
|
||||||
|
|
||||||
|
response = self.client.get(reverse('next_question'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Q1")
|
||||||
|
|
||||||
|
# Now change filter to Course 2
|
||||||
|
session['quiz_filter_course_id'] = self.course2.id
|
||||||
|
session.save()
|
||||||
|
response = self.client.get(reverse('next_question'))
|
||||||
|
self.assertContains(response, "Q2")
|
||||||
|
|
||||||
|
def test_filter_by_type(self):
|
||||||
|
session = self.client.session
|
||||||
|
session['quiz_filter_types'] = ['multi']
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse('next_question'))
|
||||||
|
self.assertContains(response, "Q2") # Q2 is multi (A,B)
|
||||||
|
|
||||||
|
session['quiz_filter_types'] = ['single']
|
||||||
|
session.save()
|
||||||
|
|
||||||
|
response = self.client.get(reverse('next_question'))
|
||||||
|
self.assertContains(response, "Q1") # Q1 is single (A)
|
||||||
Reference in New Issue
Block a user