diff --git a/.gitignore b/.gitignore index b3ce36d..964c539 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ public wip/output content/.obsidian/workspace.json content/.obsidian/plugins/text-extractor/cache +*.sqlite3 +*.sqlite3-shm +*.sqlite3-wal +*.pyc diff --git a/quiz/db.sqlite3 b/quiz/db.sqlite3 index 11dd659..9ba37b6 100644 Binary files a/quiz/db.sqlite3 and b/quiz/db.sqlite3 differ diff --git a/quiz/db.sqlite3-shm b/quiz/db.sqlite3-shm index e75ee8c..5006058 100644 Binary files a/quiz/db.sqlite3-shm and b/quiz/db.sqlite3-shm differ diff --git a/quiz/db.sqlite3-wal b/quiz/db.sqlite3-wal index 4ca6d5b..e0c5139 100644 Binary files a/quiz/db.sqlite3-wal and b/quiz/db.sqlite3-wal differ diff --git a/quiz/quiz/__pycache__/urls.cpython-313.pyc b/quiz/quiz/__pycache__/urls.cpython-313.pyc index 40483d3..fb0c755 100644 Binary files a/quiz/quiz/__pycache__/urls.cpython-313.pyc and b/quiz/quiz/__pycache__/urls.cpython-313.pyc differ diff --git a/quiz/quiz/__pycache__/views.cpython-313.pyc b/quiz/quiz/__pycache__/views.cpython-313.pyc index 427bae0..c05491a 100644 Binary files a/quiz/quiz/__pycache__/views.cpython-313.pyc and b/quiz/quiz/__pycache__/views.cpython-313.pyc differ diff --git a/quiz/quiz/forms.py b/quiz/quiz/forms.py new file mode 100644 index 0000000..6b7dfce --- /dev/null +++ b/quiz/quiz/forms.py @@ -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'}) + ) diff --git a/quiz/quiz/management/commands/__pycache__/import_questions.cpython-313.pyc b/quiz/quiz/management/commands/__pycache__/import_questions.cpython-313.pyc index 0eeaa8f..6742996 100644 Binary files a/quiz/quiz/management/commands/__pycache__/import_questions.cpython-313.pyc and b/quiz/quiz/management/commands/__pycache__/import_questions.cpython-313.pyc differ diff --git a/quiz/quiz/management/commands/import_questions.py b/quiz/quiz/management/commands/import_questions.py index 98cf133..af674fd 100644 --- a/quiz/quiz/management/commands/import_questions.py +++ b/quiz/quiz/management/commands/import_questions.py @@ -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) diff --git a/quiz/quiz/urls.py b/quiz/quiz/urls.py index 40c4526..1c5b566 100644 --- a/quiz/quiz/urls.py +++ b/quiz/quiz/urls.py @@ -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'), ] diff --git a/quiz/quiz/utils/__pycache__/importer.cpython-313.pyc b/quiz/quiz/utils/__pycache__/importer.cpython-313.pyc index c615d8b..77cc75f 100644 Binary files a/quiz/quiz/utils/__pycache__/importer.cpython-313.pyc and b/quiz/quiz/utils/__pycache__/importer.cpython-313.pyc differ diff --git a/quiz/quiz/utils/importer.py b/quiz/quiz/utils/importer.py index 802b863..2b87557 100644 --- a/quiz/quiz/utils/importer.py +++ b/quiz/quiz/utils/importer.py @@ -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: diff --git a/quiz/quiz/views.py b/quiz/quiz/views.py index de0efa0..afc3af4 100644 --- a/quiz/quiz/views.py +++ b/quiz/quiz/views.py @@ -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() diff --git a/quiz/templates/index.html b/quiz/templates/index.html index 370ac9d..3fcb619 100644 --- a/quiz/templates/index.html +++ b/quiz/templates/index.html @@ -2,7 +2,10 @@ {% block content %} + +

Create New Quiz

+ +
+ {% csrf_token %} + +
+ + {{ form.course }} +
+ +
+ + {{ form.tags }} + Hold Ctrl (or Cmd) to select multiple tags. +
+ +
+ + {{ form.question_type }} + Hold Ctrl (or Cmd) to select multiple + types. +
+ + +
+ +

Back to Dashboard

+{% endblock %} \ No newline at end of file diff --git a/quiz/tests/test_quiz_creation.py b/quiz/tests/test_quiz_creation.py new file mode 100644 index 0000000..b42531c --- /dev/null +++ b/quiz/tests/test_quiz_creation.py @@ -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)