update
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -6,3 +6,7 @@ public
|
||||
wip/output
|
||||
content/.obsidian/workspace.json
|
||||
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',
|
||||
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):
|
||||
import_folder = options['folder']
|
||||
@@ -24,7 +29,7 @@ class Command(BaseCommand):
|
||||
|
||||
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
|
||||
output = stats.format_output(show_if_no_changes=False)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
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 = [
|
||||
path('admin/', admin.site.urls),
|
||||
@@ -9,5 +9,6 @@ urlpatterns = [
|
||||
path('next/', get_next_question, name='next_question'),
|
||||
path('submit/', submit_answer, name='submit_answer'),
|
||||
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:
|
||||
if line.strip() == '---':
|
||||
in_frontmatter = not in_frontmatter
|
||||
if in_frontmatter:
|
||||
# End of frontmatter
|
||||
in_frontmatter = False
|
||||
break
|
||||
else:
|
||||
in_frontmatter = True
|
||||
continue
|
||||
if in_frontmatter and 'frågetyp/' in line:
|
||||
is_question = True
|
||||
# Extract question type
|
||||
if 'frågetyp/mcq' in line:
|
||||
question_type = 'mcq'
|
||||
elif 'frågetyp/scq' in line:
|
||||
question_type = 'scq'
|
||||
elif 'frågetyp/textalternativ' in line:
|
||||
question_type = 'textalternativ'
|
||||
elif 'frågetyp/textfält' in line:
|
||||
question_type = 'textfält'
|
||||
elif in_frontmatter and line.strip().lower().startswith('tags:'):
|
||||
# Extract tags
|
||||
# Handle: tags: [tag1, tag2] or tags: tag1, tag2
|
||||
tag_content = line.split(':', 1)[1].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 in_frontmatter:
|
||||
if 'frågetyp/' in line:
|
||||
is_question = True
|
||||
# Extract question type
|
||||
if 'frågetyp/mcq' in line:
|
||||
question_type = 'mcq'
|
||||
elif 'frågetyp/scq' in line:
|
||||
question_type = 'scq'
|
||||
elif 'frågetyp/textalternativ' in line:
|
||||
question_type = 'textalternativ'
|
||||
elif 'frågetyp/textfält' in line:
|
||||
question_type = 'textfält'
|
||||
|
||||
if line.strip().lower().startswith('tags:'):
|
||||
# Extract tags
|
||||
# Handle: tags: [tag1, tag2] or tags: tag1, tag2
|
||||
tag_content = line.split(':', 1)[1].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:
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.http import HttpResponse, HttpResponseRedirect
|
||||
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.db.models import Q
|
||||
|
||||
from .models import Question, QuizResult, Tag
|
||||
from .forms import CreateQuizForm
|
||||
|
||||
|
||||
def handle_tag_filter(request):
|
||||
@@ -14,6 +17,35 @@ def handle_tag_filter(request):
|
||||
else:
|
||||
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):
|
||||
handle_tag_filter(request)
|
||||
total_questions = Question.objects.count()
|
||||
@@ -34,13 +66,40 @@ def get_next_question(request):
|
||||
|
||||
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)
|
||||
|
||||
questions = Question.objects.exclude(id__in=answered_ids)
|
||||
|
||||
# Apply filters
|
||||
if 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()
|
||||
|
||||
if not next_question:
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.filter-section { margin-bottom: 20px; }
|
||||
.filter-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
display: inline-block;
|
||||
padding: 5px 12px;
|
||||
@@ -14,13 +17,16 @@
|
||||
font-size: 14px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.tag-chip.active {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tag-chip:hover {
|
||||
background: #d5d5d5;
|
||||
}
|
||||
|
||||
.tag-chip.active:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
@@ -29,18 +35,19 @@
|
||||
<h1>Quiz Application</h1>
|
||||
|
||||
<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>
|
||||
{% for tag in tags %}
|
||||
<a href="?tag={{ tag.slug }}" class="tag-chip {% if current_tag == tag.slug %}active{% endif %}">
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
<a href="?tag={{ tag.slug }}" class="tag-chip {% if current_tag == tag.slug %}active{% endif %}">
|
||||
{{ tag.name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<p>Besvarade frågor: {{ answered_count }} / {{ total_questions }}</p>
|
||||
<div id="quiz-container" hx-get="{% url 'next_question' %}" hx-trigger="load"></div>
|
||||
{% 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