1
0
This commit is contained in:
2025-12-22 03:19:48 +01:00
parent d6bd08d11f
commit f1c93c47b6
16 changed files with 288 additions and 31 deletions

4
.gitignore vendored
View File

@@ -6,3 +6,7 @@ public
wip/output
content/.obsidian/workspace.json
content/.obsidian/plugins/text-extractor/cache
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
*.pyc

Binary file not shown.

Binary file not shown.

Binary file not shown.

29
quiz/quiz/forms.py Normal file
View 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'})
)

View File

@@ -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)

View File

@@ -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'),
]

View File

@@ -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:

View File

@@ -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:

View File

@@ -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 %}

View 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 %}

View 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)