Compare commits
2 Commits
d6bd08d11f
...
6e79332a33
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e79332a33 | |||
| f1c93c47b6 |
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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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()
|
||||
@@ -33,13 +65,40 @@ def get_next_question(request):
|
||||
handle_tag_filter(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()
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
{% 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 %}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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