diff --git a/content/.obsidian/workspace.json b/content/.obsidian/workspace.json index 3a84831..62ef886 100644 --- a/content/.obsidian/workspace.json +++ b/content/.obsidian/workspace.json @@ -13,33 +13,31 @@ "state": { "type": "markdown", "state": { - "file": "Anatomi & Histologi 2/Statistik.md", + "file": "Anatomi & Histologi 2/Gamla tentor/2023-05-31/8.md", "mode": "source", - "source": false, + "source": true, "backlinks": false }, "icon": "lucide-file", - "title": "Statistik" + "title": "8" } } ] }, { - "id": "a3f459e33e387813", + "id": "7e72057acf1e42f0", "type": "tabs", "children": [ { - "id": "088f03735e333992", + "id": "c1c7815735aa906e", "type": "leaf", - "pinned": true, "state": { "type": "pdf", "state": { - "file": "Anatomi & Histologi 2/Gamla tentor/2023-01-11/!2023-01-11-0044-PRX.pdf" + "file": "Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf" }, - "pinned": true, "icon": "lucide-file-text", - "title": "!2023-01-11-0044-PRX" + "title": "!2023-05-31-0100-DKS" } } ] @@ -213,9 +211,17 @@ }, "active": "baa45c5e57825965", "lastOpenFiles": [ - "Anatomi & Histologi 2/Gamla tentor/2023-01-11/1.md", + "Anatomi & Histologi 2/Gamla tentor/2023-05-31/7.md", + "Anatomi & Histologi 2/Gamla tentor/2023-05-31/6.md", + "Anatomi & Histologi 2/Gamla tentor/2023-05-31/5.md", + "Anatomi & Histologi 2/Gamla tentor/2023-05-31/4.md", + "Anatomi & Histologi 2/Gamla tentor/2023-05-31/3.md", + "Anatomi & Histologi 2/Gamla tentor/2023-05-31/2.md", "Anatomi & Histologi 2/Gamla tentor/2023-05-31/1.md", + "Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf", + "Anatomi & Histologi 2/Schema.md", "Anatomi & Histologi 2/Statistik.md", + "Anatomi & Histologi 2/Gamla tentor/2023-01-11/1.md", "Anatomi & Histologi 2/Gamla tentor/2023-01-11/29.md", "Anatomi & Histologi 2/Gamla tentor/2023-01-11/30.md", "Anatomi & Histologi 2/Gamla tentor/2023-01-11/28.md", @@ -233,13 +239,6 @@ "Anatomi & Histologi 2/Gamla tentor/2023-01-11/17.md", "Anatomi & Histologi 2/Gamla tentor/2023-01-11/16.md", "Anatomi & Histologi 2/Gamla tentor/2023-01-11/15.md", - "Anatomi & Histologi 2/Gamla tentor/2023-01-11/14.md", - "Anatomi & Histologi 2/Gamla tentor/2023-01-11/13.md", - "Anatomi & Histologi 2/Gamla tentor/2023-01-11/12.md", - "Anatomi & Histologi 2/Gamla tentor/2023-01-11/11.md", - "Anatomi & Histologi 2/Gamla tentor/2023-01-11/10.md", - "Anatomi & Histologi 2/Gamla tentor/2023-01-11/9.md", - "Anatomi & Histologi 2/Gamla tentor/2023-01-11/8.md", "Anatomi & Histologi 2/Gamla tentor/2022-06-01/!2022-06-01-0101-MGY.pdf", "Anatomi & Histologi 2/Gamla tentor/2022-01-15/!2022-01-15-0032-BWD.pdf", "attachments/image-121.png", @@ -258,7 +257,6 @@ "attachments/image-112.png", "Anatomi & Histologi 2/Gamla tentor/2025-06-03/!2025-06-03-0003-UJR.pdf", "Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08-0003-ESW.pdf", - "Anatomi & Histologi 2/Gamla tentor/2025-01-15/!2025-01-15-0021-HRY.pdf", "Untitled.canvas", "Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas", "Biokemi/Metabolism/📋 Metabolismen översikt.canvas", diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/1.md b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/1.md index 3412e33..2cbf82c 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/1.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/1.md @@ -11,5 +11,5 @@ Var sitter de nedre motorneuronens cellkroppar (ett- eller flera alternativ Ă€r - D: Cortex cerebri, lobus frontalis ```spoiler-block: -TODO +A och B ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/2.md b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/2.md index e4be3e5..7dbd6c8 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/2.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/2.md @@ -1,5 +1,5 @@ --- -tags: [ah2, provfrĂ„ga, frĂ„getyp/mcq, anatomi] +tags: [ah2, provfrĂ„ga, frĂ„getyp/scq, anatomi] date: 2023-05-31 --- Vilken hjĂ€rnhinna innesluter cerebrospinalvĂ€tska och blodkĂ€rl? (1p) @@ -13,5 +13,5 @@ Vilken hjĂ€rnhinna innesluter cerebrospinalvĂ€tska och blodkĂ€rl? (1p) - E: Dura mater ```spoiler-block: -TODO +A ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/3.md b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/3.md index 9db6188..3fee31b 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/3.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/3.md @@ -3,8 +3,13 @@ tags: [ah2, provfrĂ„ga, frĂ„getyp/matching, anatomi, öra] date: 2023-05-31 --- **Matcha rĂ€tt funktion med rĂ€tt lob:** -- (1p för alla rĂ€tt, inga delpoĂ€ng) -- Smak Syn Somatosensorik Motorik Hörsel +(1p för alla rĂ€tt, inga delpoĂ€ng) +- Smak +- Syn +- Somatosensorik +- Motorik +- Hörsel + - Lobus frontalis - Lobus Insularis - Lobus temporalis @@ -12,5 +17,9 @@ date: 2023-05-31 - Lobus occipitalis ```spoiler-block: -TODO +Smak: Lobus Insularis +Syn: Lobus occipitalis +Somatosensorik: Lobus parietalis +Motorik: Lobus frontalis +Hörsel: Lobus temporalis ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/4.md b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/4.md index 8a7f5b6..8421ddf 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/4.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/4.md @@ -9,12 +9,11 @@ date: 2023-05-31 --- ![[image-32.png]] Vilken siffra/bokstav pekar pĂ„ - -a) Corpus callosum - -b) Corpus pineale +a) Corpus callosum (1..19) +b) Corpus pineale (1..19) (0,5p per rĂ€tt svar, totalt 1p) ```spoiler-block: -TODO +a) 2 +b) 12 ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/5.md b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/5.md index 1913258..e1abd7c 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/5.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/5.md @@ -15,5 +15,5 @@ Om jag sĂ€ger ”reglering av vĂ„ra hormoner” - F: Hypofysen ```spoiler-block: -TODO +D och F ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/7.md b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/7.md index a732677..f0b3207 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/7.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/7.md @@ -11,5 +11,5 @@ Vilka tvĂ„ av följande pĂ„stĂ„ende beskriver bĂ€st limbiska systemet? (1p) - D: En del av limbiska systemet Ă€r hippocampus, som bestĂ„r av cortex cerebri (om Ă€n med egen histologi) ```spoiler-block: -TODO +A och D ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/8.md b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/8.md index 9fec70d..cb0e8a5 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/8.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2023-05-31/8.md @@ -2,14 +2,13 @@ tags: [ah2, provfrĂ„ga, frĂ„getyp/textalternativ, anatomi] date: 2023-05-31 --- -Vilken del av hjĂ€rnstammen tar emot- -, kopplar om och skickar information vidare frĂ„n cerebrum till cerebellum? (1p) -FrĂ€mre delen av pons -Medulla oblongata). +Vilken del av hjĂ€rnstammen tar emot-, kopplar om och skickar information vidare frĂ„n cerebrum till cerebellum? (1p) + - Pedunculus cerebellaris - FrĂ€mre delen av pons - Mesencephalon +- Medulla oblongata ```spoiler-block: -TODO +FrĂ€mre delen av pons ``` diff --git a/quiz/ADMIN_README.md b/quiz/ADMIN_README.md deleted file mode 100644 index e69de29..0000000 diff --git a/quiz/db.sqlite3-wal b/quiz/db.sqlite3-wal index 716a0db..066729a 100644 Binary files a/quiz/db.sqlite3-wal and b/quiz/db.sqlite3-wal differ diff --git a/quiz/pytest.ini b/quiz/pytest.ini new file mode 100644 index 0000000..08d9cd4 --- /dev/null +++ b/quiz/pytest.ini @@ -0,0 +1,17 @@ +[pytest] +DJANGO_SETTINGS_MODULE = settings +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --verbose + --strict-markers + --tb=short + --reuse-db +testpaths = tests +markers = + admin: Admin interface tests + import: Import and parsing tests + import_tests: Import and parsing tests + slow: Slow running tests + diff --git a/quiz/quiz/__pycache__/admin.cpython-314.pyc b/quiz/quiz/__pycache__/admin.cpython-314.pyc index 69e57a1..f494497 100644 Binary files a/quiz/quiz/__pycache__/admin.cpython-314.pyc and b/quiz/quiz/__pycache__/admin.cpython-314.pyc differ diff --git a/quiz/quiz/__pycache__/apps.cpython-314.pyc b/quiz/quiz/__pycache__/apps.cpython-314.pyc index 69d8737..ac61c9a 100644 Binary files a/quiz/quiz/__pycache__/apps.cpython-314.pyc and b/quiz/quiz/__pycache__/apps.cpython-314.pyc differ diff --git a/quiz/quiz/__pycache__/middleware.cpython-314.pyc b/quiz/quiz/__pycache__/middleware.cpython-314.pyc index 9a7f48e..4b1c8a0 100644 Binary files a/quiz/quiz/__pycache__/middleware.cpython-314.pyc and b/quiz/quiz/__pycache__/middleware.cpython-314.pyc differ diff --git a/quiz/quiz/__pycache__/models.cpython-314.pyc b/quiz/quiz/__pycache__/models.cpython-314.pyc index 94fe7a0..ee68148 100644 Binary files a/quiz/quiz/__pycache__/models.cpython-314.pyc and b/quiz/quiz/__pycache__/models.cpython-314.pyc differ diff --git a/quiz/quiz/__pycache__/tests.cpython-314.pyc b/quiz/quiz/__pycache__/tests.cpython-314.pyc new file mode 100644 index 0000000..6cfa65f Binary files /dev/null and b/quiz/quiz/__pycache__/tests.cpython-314.pyc differ diff --git a/quiz/quiz/__pycache__/views.cpython-314.pyc b/quiz/quiz/__pycache__/views.cpython-314.pyc index c04a4d9..6bf4348 100644 Binary files a/quiz/quiz/__pycache__/views.cpython-314.pyc and b/quiz/quiz/__pycache__/views.cpython-314.pyc differ diff --git a/quiz/quiz/admin.py b/quiz/quiz/admin.py index 9f032a3..0b3920d 100644 --- a/quiz/quiz/admin.py +++ b/quiz/quiz/admin.py @@ -1,6 +1,34 @@ from django.contrib import admin from django.utils.html import format_html -from .models import User, Question, Option, QuizResult +from django.utils.safestring import mark_safe +from .models import QuizUser, Question, Option, QuizResult, Course, Exam + + +@admin.register(Course) +class CourseAdmin(admin.ModelAdmin): + """Admin interface for Courses""" + list_display = ['id', 'name', 'code', 'exam_count', 'created_at'] + search_fields = ['name', 'code'] + readonly_fields = ['created_at'] + + def exam_count(self, obj): + """Show number of exams""" + return obj.exams.count() + exam_count.short_description = '# Exams' + + +@admin.register(Exam) +class ExamAdmin(admin.ModelAdmin): + """Admin interface for Exams""" + list_display = ['id', 'course', 'date', 'question_count', 'folder_path', 'created_at'] + list_filter = ['course', 'date'] + search_fields = ['name', 'folder_path'] + readonly_fields = ['created_at'] + + def question_count(self, obj): + """Show number of questions""" + return obj.questions.count() + question_count.short_description = '# Questions' class OptionInline(admin.TabularInline): @@ -14,13 +42,13 @@ class OptionInline(admin.TabularInline): @admin.register(Question) class QuestionAdmin(admin.ModelAdmin): """Admin interface for Questions""" - list_display = ['id', 'question_preview', 'correct_answer', 'option_count', 'file_source', 'updated_at'] - list_filter = ['created_at', 'updated_at'] + list_display = ['id', 'question_preview', 'exam', 'correct_answer', 'option_count', 'file_source', 'updated_at'] + list_filter = ['exam__course', 'exam', 'created_at', 'updated_at'] search_fields = ['text', 'file_path', 'correct_answer'] readonly_fields = ['file_path', 'file_mtime', 'created_at', 'updated_at', 'formatted_mtime'] fieldsets = [ ('Question Content', { - 'fields': ['text', 'correct_answer'] + 'fields': ['exam', 'text', 'correct_answer'] }), ('File Tracking', { 'fields': ['file_path', 'file_mtime', 'formatted_mtime'], @@ -90,9 +118,9 @@ class OptionAdmin(admin.ModelAdmin): is_correct.short_description = 'Status' -@admin.register(User) -class UserAdmin(admin.ModelAdmin): - """Admin interface for Users""" +@admin.register(QuizUser) +class QuizUserAdmin(admin.ModelAdmin): + """Admin interface for Quiz Users""" list_display = ['id', 'session_preview', 'result_count', 'score_percentage', 'created_at'] list_filter = ['created_at'] search_fields = ['session_key'] @@ -121,9 +149,8 @@ class UserAdmin(admin.ModelAdmin): correct = obj.results.filter(is_correct=True).count() percentage = (correct / total * 100) color = 'green' if percentage >= 70 else 'orange' if percentage >= 50 else 'red' - return format_html( - '{:.1f}% ({}/{})', - color, percentage, correct, total + return mark_safe( + f'{percentage:.1f}% ({correct}/{total})' ) score_percentage.short_description = 'Score' @@ -159,7 +186,7 @@ class QuizResultAdmin(admin.ModelAdmin): def result_status(self, obj): """Show visual result status""" if obj.is_correct: - return format_html('✓ Correct') - return format_html('✗ Wrong') + return mark_safe('✓ Correct') + return mark_safe('✗ Wrong') result_status.short_description = 'Result' diff --git a/quiz/quiz/apps.py b/quiz/quiz/apps.py index 2a08bc2..69b56bc 100644 --- a/quiz/quiz/apps.py +++ b/quiz/quiz/apps.py @@ -13,10 +13,11 @@ class QuizAppConfig(AppConfig): Starts the auto-import watcher in a background thread. """ # Only run in the main process (not in reloader process) - # And not during management commands (to avoid database locking) + # And not during management commands or pytest tests is_management_command = any(cmd in sys.argv for cmd in ['import_questions', 'migrate', 'makemigrations', 'shell']) + is_pytest = 'pytest' in sys.argv[0] or 'PYTEST_CURRENT_TEST' in os.environ - if not is_management_command and (os.environ.get('RUN_MAIN') == 'true' or os.environ.get('RUN_MAIN') is None): + if not is_management_command and not is_pytest and (os.environ.get('RUN_MAIN') == 'true' or os.environ.get('RUN_MAIN') is None): from quiz.utils.watcher import start_watcher_thread start_watcher_thread() diff --git a/quiz/quiz/management/commands/__pycache__/populate_exams.cpython-314.pyc b/quiz/quiz/management/commands/__pycache__/populate_exams.cpython-314.pyc new file mode 100644 index 0000000..f691ae1 Binary files /dev/null and b/quiz/quiz/management/commands/__pycache__/populate_exams.cpython-314.pyc differ diff --git a/quiz/quiz/management/commands/populate_exams.py b/quiz/quiz/management/commands/populate_exams.py new file mode 100644 index 0000000..2a70b46 --- /dev/null +++ b/quiz/quiz/management/commands/populate_exams.py @@ -0,0 +1,87 @@ +""" +Management command to populate Course and Exam models from existing questions. +""" +from django.core.management.base import BaseCommand +from django.conf import settings +from quiz.models import Course, Exam, Question +from datetime import datetime +from pathlib import Path + + +class Command(BaseCommand): + help = 'Populate Course and Exam models from existing question file paths' + + def handle(self, *args, **options): + # Create default course + course, created = Course.objects.get_or_create( + name="Anatomi & Histologi 2", + defaults={'code': 'AH2', 'description': 'Anatomy and Histology course'} + ) + + if created: + self.stdout.write(self.style.SUCCESS(f'Created course: {course.name}')) + else: + self.stdout.write(f'Course exists: {course.name}') + + # Analyze existing questions and create exams + questions = Question.objects.all() + exam_folders = {} + + for question in questions: + # Extract exam date from file path + # Expected: content/Anatomi & Histologi 2/Gamla tentor/2022-01-15/1.md + path_parts = Path(question.file_path).parts + + if len(path_parts) >= 2: + # Try to find a date-like folder + for part in path_parts: + if '-' in part and len(part) == 10: # Looks like YYYY-MM-DD + try: + exam_date = datetime.strptime(part, '%Y-%m-%d').date() + folder_path = '/'.join(path_parts[:-1]) + + if part not in exam_folders: + exam_folders[part] = { + 'date': exam_date, + 'folder': folder_path, + 'questions': [] + } + exam_folders[part]['questions'].append(question) + break + except ValueError: + continue + + # Create exams and assign questions + exams_created = 0 + questions_assigned = 0 + + for folder_name, data in sorted(exam_folders.items()): + exam, created = Exam.objects.get_or_create( + course=course, + date=data['date'], + defaults={ + 'name': folder_name, + 'folder_path': data['folder'] + } + ) + + if created: + exams_created += 1 + self.stdout.write(self.style.SUCCESS(f' Created exam: {exam.date}')) + + # Assign questions to this exam + for question in data['questions']: + if question.exam != exam: + question.exam = exam + question.save(update_fields=['exam']) + questions_assigned += 1 + + self.stdout.write(self.style.SUCCESS( + f'\nSummary:\n' + f' Exams created: {exams_created}\n' + f' Questions assigned: {questions_assigned}\n' + f' Total exams: {Exam.objects.count()}\n' + f' Questions with exams: {Question.objects.filter(exam__isnull=False).count()}\n' + f' Questions without exams: {Question.objects.filter(exam__isnull=True).count()}' + )) + diff --git a/quiz/quiz/middleware.py b/quiz/quiz/middleware.py index 9852285..c8fb0e1 100644 --- a/quiz/quiz/middleware.py +++ b/quiz/quiz/middleware.py @@ -1,4 +1,4 @@ -from .models import User +from .models import QuizUser class LazyAuthMiddleware: @@ -14,8 +14,8 @@ class LazyAuthMiddleware: request.session.create() session_key = request.session.session_key - user, created = User.objects.get_or_create(session_key=session_key) - request.user = user + user, created = QuizUser.objects.get_or_create(session_key=session_key) + request.quiz_user = user return self.get_response(request) diff --git a/quiz/quiz/migrations/0004_rename_user_quizuser_alter_quizuser_options.py b/quiz/quiz/migrations/0004_rename_user_quizuser_alter_quizuser_options.py new file mode 100644 index 0000000..b1edeae --- /dev/null +++ b/quiz/quiz/migrations/0004_rename_user_quizuser_alter_quizuser_options.py @@ -0,0 +1,21 @@ +# Generated by Django 6.0 on 2025-12-21 19:46 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('quiz', '0003_question_file_mtime'), + ] + + operations = [ + migrations.RenameModel( + old_name='User', + new_name='QuizUser', + ), + migrations.AlterModelOptions( + name='quizuser', + options={'verbose_name': 'Quiz User', 'verbose_name_plural': 'Quiz Users'}, + ), + ] diff --git a/quiz/quiz/migrations/0005_course_exam_question_exam.py b/quiz/quiz/migrations/0005_course_exam_question_exam.py new file mode 100644 index 0000000..7248a0b --- /dev/null +++ b/quiz/quiz/migrations/0005_course_exam_question_exam.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0 on 2025-12-21 20:10 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('quiz', '0004_rename_user_quizuser_alter_quizuser_options'), + ] + + operations = [ + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, unique=True)), + ('code', models.CharField(blank=True, max_length=50)), + ('description', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Exam', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date', models.DateField()), + ('name', models.CharField(blank=True, max_length=200)), + ('folder_path', models.CharField(blank=True, max_length=500)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exams', to='quiz.course')), + ], + options={ + 'ordering': ['-date'], + 'unique_together': {('course', 'date')}, + }, + ), + migrations.AddField( + model_name='question', + name='exam', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='quiz.exam'), + ), + ] diff --git a/quiz/quiz/migrations/__pycache__/0004_rename_user_quizuser_alter_quizuser_options.cpython-314.pyc b/quiz/quiz/migrations/__pycache__/0004_rename_user_quizuser_alter_quizuser_options.cpython-314.pyc new file mode 100644 index 0000000..8af889a Binary files /dev/null and b/quiz/quiz/migrations/__pycache__/0004_rename_user_quizuser_alter_quizuser_options.cpython-314.pyc differ diff --git a/quiz/quiz/migrations/__pycache__/0005_course_exam_question_exam.cpython-314.pyc b/quiz/quiz/migrations/__pycache__/0005_course_exam_question_exam.cpython-314.pyc new file mode 100644 index 0000000..80163e1 Binary files /dev/null and b/quiz/quiz/migrations/__pycache__/0005_course_exam_question_exam.cpython-314.pyc differ diff --git a/quiz/quiz/models.py b/quiz/quiz/models.py index 6cdf087..6030d88 100644 --- a/quiz/quiz/models.py +++ b/quiz/quiz/models.py @@ -1,15 +1,45 @@ from django.db import models -class User(models.Model): +class QuizUser(models.Model): session_key = models.CharField(max_length=40, unique=True) created_at = models.DateTimeField(auto_now_add=True) + class Meta: + verbose_name = "Quiz User" + verbose_name_plural = "Quiz Users" + def __str__(self): return f"User {self.session_key[:8]}" +class Course(models.Model): + name = models.CharField(max_length=200, unique=True) + code = models.CharField(max_length=50, blank=True) + description = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + +class Exam(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='exams') + date = models.DateField() + name = models.CharField(max_length=200, blank=True) # e.g., "2022-01-15" + folder_path = models.CharField(max_length=500, blank=True) # Path to exam folder in content + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ['course', 'date'] + ordering = ['-date'] + + def __str__(self): + return f"{self.course.name} - {self.date}" + + class Question(models.Model): + exam = models.ForeignKey(Exam, on_delete=models.CASCADE, related_name='questions', null=True, blank=True) file_path = models.CharField(max_length=500, unique=True) text = models.TextField() correct_answer = models.CharField(max_length=50) # Support multi-select answers like "A,B,C" @@ -34,7 +64,7 @@ class Option(models.Model): class QuizResult(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='results') + user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='results') question = models.ForeignKey(Question, on_delete=models.CASCADE) selected_answer = models.CharField(max_length=1) is_correct = models.BooleanField() diff --git a/quiz/quiz/utils/__pycache__/importer.cpython-314.pyc b/quiz/quiz/utils/__pycache__/importer.cpython-314.pyc index f18029b..85cda38 100644 Binary files a/quiz/quiz/utils/__pycache__/importer.cpython-314.pyc and b/quiz/quiz/utils/__pycache__/importer.cpython-314.pyc differ diff --git a/quiz/quiz/utils/importer.py b/quiz/quiz/utils/importer.py index 73f472c..8e22103 100644 --- a/quiz/quiz/utils/importer.py +++ b/quiz/quiz/utils/importer.py @@ -131,8 +131,15 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]: question_text = line.strip().replace('**', '') break + # Return early if no question text found, but include has_answer field if not question_text: - return True, {} + return True, { + 'text': None, + 'options': [], + 'correct_answer': '', + 'has_answer': False, + 'question_type': question_type + } # Extract options (pattern: "- A:" or "- A" for MCQ, or text for textalternativ) options_data = [] @@ -184,11 +191,17 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]: options_data.append((letter, option_text)) # For text-based questions, options are optional - if question_type in ['textfĂ€lt'] and len(options_data) == 0: - # Create a dummy option for text field questions + if not options_data: + # At least return something for single-option questions options_data = [('A', '')] elif len(options_data) < 2 and question_type in ['mcq', 'scq']: - return True, {} + return True, { + 'text': question_text, + 'options': options_data, + 'correct_answer': '', + 'has_answer': False, + 'question_type': question_type + } # Extract answer from spoiler block correct_answer = None @@ -294,10 +307,57 @@ def import_question_file(file_path: Path, base_path: Path, stats: ImportStats, f stats.questions_with_answers += 1 stats.by_folder[folder_name]['answered'] += 1 + # Extract exam information from folder structure + # Expected path: content/Anatomi & Histologi 2/Gamla tentor/2022-01-15/question.md + exam = None + relative_path = file_path.relative_to(base_path) + path_parts = relative_path.parts + + # Try to extract exam date from folder structure + if len(path_parts) >= 2: + # Get the parent folder name which should be the exam date (e.g., "2022-01-15") + exam_folder = path_parts[-2] if len(path_parts) > 1 else None + + # Try to parse as date + if exam_folder and '-' in exam_folder: + try: + from datetime import datetime + from quiz.models import Course, Exam + + exam_date = datetime.strptime(exam_folder, '%Y-%m-%d').date() + + # Get or create course (default to "Anatomi & Histologi 2") + # Extract course name from path if available + course_name = "Anatomi & Histologi 2" + if len(path_parts) >= 3 and 'Anatomi' in ' '.join(path_parts): + # Try to find course name in path + for part in path_parts: + if 'Anatomi' in part or 'Histologi' in part: + course_name = part + break + + course, _ = Course.objects.get_or_create( + name=course_name, + defaults={'code': 'AH2'} + ) + + # Get or create exam + exam, _ = Exam.objects.get_or_create( + course=course, + date=exam_date, + defaults={ + 'name': exam_folder, + 'folder_path': '/'.join(path_parts[:-1]) + } + ) + except (ValueError, ImportError): + pass # If date parsing fails, exam remains None + # Import to database with mtime tracking question, created = Question.objects.update_or_create( file_path=file_path_str, defaults={ + 'exam': exam, 'text': question_data['text'], 'correct_answer': question_data['correct_answer'], 'file_mtime': file_mtime, # Track modification time diff --git a/quiz/quiz/views.py b/quiz/quiz/views.py index c26475e..1d66f56 100644 --- a/quiz/quiz/views.py +++ b/quiz/quiz/views.py @@ -7,7 +7,7 @@ from .models import Question, QuizResult def index(request): total_questions = Question.objects.count() - answered_count = QuizResult.objects.filter(user=request.user).count() + answered_count = QuizResult.objects.filter(user=request.quiz_user).count() context = { 'total_questions': total_questions, @@ -17,7 +17,7 @@ def index(request): def get_next_question(request): - answered_ids = QuizResult.objects.filter(user=request.user).values_list('question_id', flat=True) + answered_ids = QuizResult.objects.filter(user=request.quiz_user).values_list('question_id', flat=True) next_question = Question.objects.exclude(id__in=answered_ids).first() if not next_question: @@ -42,7 +42,7 @@ def submit_answer(request): is_correct = selected_answer == question.correct_answer QuizResult.objects.update_or_create( - user=request.user, + user=request.quiz_user, question=question, defaults={ 'selected_answer': selected_answer, @@ -54,7 +54,7 @@ def submit_answer(request): def stats(request): - results = QuizResult.objects.filter(user=request.user) + results = QuizResult.objects.filter(user=request.quiz_user) total = results.count() correct = results.filter(is_correct=True).count() diff --git a/quiz/tests/__init__.py b/quiz/tests/__init__.py new file mode 100644 index 0000000..20af3ab --- /dev/null +++ b/quiz/tests/__init__.py @@ -0,0 +1,2 @@ +# This makes tests a package + diff --git a/quiz/tests/__pycache__/__init__.cpython-314.pyc b/quiz/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..b726bca Binary files /dev/null and b/quiz/tests/__pycache__/__init__.cpython-314.pyc differ diff --git a/quiz/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc b/quiz/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..6d03326 Binary files /dev/null and b/quiz/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc differ diff --git a/quiz/tests/__pycache__/test_admin.cpython-314-pytest-9.0.2.pyc b/quiz/tests/__pycache__/test_admin.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..c975439 Binary files /dev/null and b/quiz/tests/__pycache__/test_admin.cpython-314-pytest-9.0.2.pyc differ diff --git a/quiz/tests/__pycache__/test_import.cpython-314-pytest-9.0.2.pyc b/quiz/tests/__pycache__/test_import.cpython-314-pytest-9.0.2.pyc new file mode 100644 index 0000000..eb82896 Binary files /dev/null and b/quiz/tests/__pycache__/test_import.cpython-314-pytest-9.0.2.pyc differ diff --git a/quiz/tests/conftest.py b/quiz/tests/conftest.py new file mode 100644 index 0000000..33b5b37 --- /dev/null +++ b/quiz/tests/conftest.py @@ -0,0 +1,108 @@ +import pytest +from django.conf import settings + + +@pytest.fixture(scope='session') +def django_db_setup(): + """Configure test database""" + settings.DATABASES['default'] = { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + 'ATOMIC_REQUESTS': False, + 'AUTOCOMMIT': True, + 'CONN_MAX_AGE': 0, + 'OPTIONS': {}, + 'TIME_ZONE': None, + 'USER': '', + 'PASSWORD': '', + 'HOST': '', + 'PORT': '', + 'TEST': { + 'NAME': None, + }, + } + + +@pytest.fixture +def sample_mcq_content(): + """Fixture for standard MCQ content""" + return """--- +tags: [ah2, provfrĂ„ga, frĂ„getyp/mcq, cerebrum] +date: 2022-01-15 +--- +Vilka av följande rĂ€knas till storhjĂ€rnans basala kĂ€rnor? + +**VĂ€lj tvĂ„ alternativ** +- A: Putamen +- B: Nucleus Ruber +- C: Substantia nigra +- D: Nucleus caudatus + +```spoiler-block: +A och D +``` +""" + + +@pytest.fixture +def sample_scq_content(): + """Fixture for standard SCQ content""" + return """--- +tags: [ah2, provfrĂ„ga, frĂ„getyp/scq, anatomi] +date: 2022-01-15 +--- +What is the correct answer? + +**VĂ€lj ett alternativ:** +- A: Wrong answer +- B: Correct answer +- C: Another wrong + +```spoiler-block: +B +``` +""" + + +@pytest.fixture +def sample_textalternativ_content(): + """Fixture for text alternative question""" + return """--- +tags: [frĂ„getyp/textalternativ, öga, anatomi] +--- +Svara pĂ„ följande frĂ„gor: + +a) Bokstaven B sitter i en lob, vilken? +- Lobus temporalis +- Lobus frontalis +- Lobus parietalis + +b) Vilket funktionellt centra Ă„terfinns dĂ€r? +- Syncentrum +- Motorcentrum +- Somatosensoriskt centrum + +```spoiler-block: +a) Lobus parietalis +b) Somatosensoriskt centrum +``` +""" + + +@pytest.fixture +def sample_textfalt_content(): + """Fixture for text field question""" + return """--- +tags: [frĂ„getyp/textfĂ€lt, öga] +--- +**Fyll i rĂ€tt siffra!** + +a) Vilken siffra pekar pĂ„ gula flĂ€cken? +b) Vilken siffra pekar pĂ„ choroidea? + +```spoiler-block: +a) 7 +b) 6 +``` +""" + diff --git a/quiz/tests/test_admin.py b/quiz/tests/test_admin.py new file mode 100644 index 0000000..5954951 --- /dev/null +++ b/quiz/tests/test_admin.py @@ -0,0 +1,184 @@ +import pytest +from django.contrib.auth.models import User +from django.urls import reverse +from quiz.models import QuizUser, Question, Option, QuizResult + + +@pytest.mark.django_db +@pytest.mark.admin +class TestAdminPages: + """Test that all admin pages render without errors""" + + @pytest.fixture + def admin_client(self, client, django_user_model, db): + """Create authenticated admin client""" + admin_user = django_user_model.objects.create_superuser( + username='testadmin', + email='admin@test.com', + password='admin123' + ) + client.login(username='testadmin', password='admin123') + return client + + @pytest.fixture + def test_data(self, db): + """Create test data""" + quiz_user = QuizUser.objects.create(session_key='test_session_123') + + question = Question.objects.create( + file_path='test/question1.md', + text='Test question?', + correct_answer='A', + file_mtime=1234567890.0 + ) + + Option.objects.create(question=question, letter='A', text='Correct answer') + Option.objects.create(question=question, letter='B', text='Wrong answer') + + quiz_result = QuizResult.objects.create( + user=quiz_user, + question=question, + selected_answer='A', + is_correct=True + ) + + return { + 'quiz_user': quiz_user, + 'question': question, + 'quiz_result': quiz_result + } + + def test_admin_index(self, admin_client): + """Test admin index page""" + response = admin_client.get(reverse('admin:index')) + assert response.status_code == 200 + assert 'Site administration' in response.content.decode() + + def test_question_changelist(self, admin_client, test_data): + """Test Question list page""" + response = admin_client.get(reverse('admin:quiz_question_changelist')) + assert response.status_code == 200 + assert 'Test question?' in response.content.decode() + + def test_question_add(self, admin_client): + """Test Question add page""" + response = admin_client.get(reverse('admin:quiz_question_add')) + assert response.status_code == 200 + assert 'Add question' in response.content.decode() + + def test_question_change(self, admin_client, test_data): + """Test Question change/edit page""" + response = admin_client.get( + reverse('admin:quiz_question_change', args=[test_data['question'].pk]) + ) + assert response.status_code == 200 + assert 'Test question?' in response.content.decode() + assert 'Correct answer' in response.content.decode() + + def test_question_delete(self, admin_client, test_data): + """Test Question delete page""" + response = admin_client.get( + reverse('admin:quiz_question_delete', args=[test_data['question'].pk]) + ) + assert response.status_code == 200 + assert 'Are you sure' in response.content.decode() + + def test_option_add(self, admin_client): + """Test Option add page""" + response = admin_client.get(reverse('admin:quiz_option_add')) + assert response.status_code == 200 + assert 'Add option' in response.content.decode() + + def test_option_change(self, admin_client, test_data): + """Test Option change/edit page""" + option = test_data['question'].options.first() + response = admin_client.get( + reverse('admin:quiz_option_change', args=[option.pk]) + ) + assert response.status_code == 200 + assert 'Correct answer' in response.content.decode() + + def test_quizuser_changelist(self, admin_client, test_data): + """Test QuizUser list page""" + response = admin_client.get(reverse('admin:quiz_quizuser_changelist')) + assert response.status_code == 200 + assert 'test_session' in response.content.decode() + + def test_quizuser_add(self, admin_client): + """Test QuizUser add page""" + response = admin_client.get(reverse('admin:quiz_quizuser_add')) + assert response.status_code == 200 + assert 'Add Quiz User' in response.content.decode() + + def test_quizuser_change(self, admin_client, test_data): + """Test QuizUser change/edit page""" + response = admin_client.get( + reverse('admin:quiz_quizuser_change', args=[test_data['quiz_user'].pk]) + ) + assert response.status_code == 200 + assert 'test_session' in response.content.decode() + + def test_quizresult_changelist(self, admin_client, test_data): + """Test QuizResult list page""" + response = admin_client.get(reverse('admin:quiz_quizresult_changelist')) + assert response.status_code == 200 + assert 'Test question' in response.content.decode() + + def test_quizresult_add(self, admin_client): + """Test QuizResult add page""" + response = admin_client.get(reverse('admin:quiz_quizresult_add')) + assert response.status_code == 200 + assert 'Add quiz result' in response.content.decode() + + def test_quizresult_change(self, admin_client, test_data): + """Test QuizResult change/edit page""" + response = admin_client.get( + reverse('admin:quiz_quizresult_change', args=[test_data['quiz_result'].pk]) + ) + assert response.status_code == 200 + assert 'Test question' in response.content.decode() + + def test_admin_custom_displays(self, admin_client, test_data): + """Test custom admin display methods render correctly""" + # Question admin with custom fields + response = admin_client.get(reverse('admin:quiz_question_changelist')) + assert 'question1.md' in response.content.decode() + + # QuizUser admin with score percentage + response = admin_client.get(reverse('admin:quiz_quizuser_changelist')) + assert '100.0%' in response.content.decode() + + # QuizResult admin with result status + response = admin_client.get(reverse('admin:quiz_quizresult_changelist')) + assert 'Correct' in response.content.decode() + + def test_admin_search(self, admin_client, test_data): + """Test admin search functionality""" + response = admin_client.get( + reverse('admin:quiz_question_changelist') + '?q=Test' + ) + assert response.status_code == 200 + assert 'Test question?' in response.content.decode() + + def test_admin_filters(self, admin_client, test_data): + """Test admin filter functionality""" + response = admin_client.get( + reverse('admin:quiz_quizresult_changelist') + '?is_correct__exact=1' + ) + assert response.status_code == 200 + + @pytest.mark.parametrize('url_name', [ + 'admin:index', + 'admin:quiz_question_changelist', + 'admin:quiz_question_add', + 'admin:quiz_quizuser_changelist', + 'admin:quiz_quizuser_add', + 'admin:quiz_quizresult_changelist', + 'admin:quiz_quizresult_add', + ]) + def test_all_admin_pages_no_errors(self, admin_client, test_data, url_name): + """Integration test: verify no admin pages return errors""" + url = reverse(url_name) + response = admin_client.get(url) + assert response.status_code == 200, f"Failed to load {url}" + diff --git a/quiz/tests/test_import.py b/quiz/tests/test_import.py new file mode 100644 index 0000000..5302915 --- /dev/null +++ b/quiz/tests/test_import.py @@ -0,0 +1,576 @@ +import pytest +from pathlib import Path +from quiz.utils.importer import parse_markdown_question, import_question_file, ImportStats +from quiz.models import Question, Option + + +@pytest.mark.django_db +@pytest.mark.import_tests +class TestMarkdownParsing: + """Test parsing of various Obsidian markdown question formats""" + + def test_parse_single_choice_question(self): + """Test parsing standard single choice question (SCQ)""" + content = """--- +tags: [ah2, provfrĂ„ga, frĂ„getyp/scq, anatomi] +date: 2022-01-15 +--- +What is the correct answer? + +**VĂ€lj ett alternativ:** +- A: Wrong answer +- B: Correct answer +- C: Another wrong + +```spoiler-block: +B +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert data['text'] == 'What is the correct answer?' + assert data['correct_answer'] == 'B' + assert data['has_answer'] is True + assert data['question_type'] == 'scq' + assert len(data['options']) == 3 + assert data['options'][0] == ('A', 'Wrong answer') + assert data['options'][1] == ('B', 'Correct answer') + + def test_parse_multiple_choice_question(self): + """Test parsing multiple choice question (MCQ) with 'och' separator""" + content = """--- +tags: [ah2, provfrĂ„ga, frĂ„getyp/mcq, cerebrum] +date: 2022-01-15 +--- +Vilka av följande rĂ€knas till storhjĂ€rnans basala kĂ€rnor? + +**VĂ€lj tvĂ„ alternativ** +- A: Putamen +- B: Nucleus Ruber +- C: Substantia nigra +- D: Nucleus caudatus + +```spoiler-block: +A och D +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert 'Vilka av följande' in data['text'] + assert data['correct_answer'] == 'A,D' # Normalized to comma-separated + assert data['has_answer'] is True + assert data['question_type'] == 'mcq' + assert len(data['options']) == 4 + + def test_parse_multiple_choice_comma_separated(self): + """Test MCQ with comma-separated answer""" + content = """--- +tags: [frĂ„getyp/mcq] +--- +Select two options: + +- A: Option A +- B: Option B +- C: Option C +- D: Option D + +```spoiler-block: +B, C +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert data['correct_answer'] == 'B,C' + assert data['has_answer'] is True + + def test_parse_options_without_colon(self): + """Test parsing options in format '- A' without text""" + content = """--- +tags: [frĂ„getyp/scq] +--- +Which letter? + +**VĂ€lj ett alternativ:** +- A +- B +- C +- D + +```spoiler-block: +C +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert len(data['options']) == 4 + assert all(text == '' for _, text in data['options']) + assert data['correct_answer'] == 'C' + + def test_parse_textalternativ_question(self): + """Test text alternative question type""" + content = """--- +tags: [frĂ„getyp/textalternativ, öga, anatomi] +--- +Svara pĂ„ följande frĂ„gor: + +a) Bokstaven B sitter i en lob, vilken? +- Lobus temporalis +- Lobus frontalis +- Lobus parietalis + +b) Vilket funktionellt centra Ă„terfinns dĂ€r? +- Syncentrum +- Motorcentrum +- Somatosensoriskt centrum + +```spoiler-block: +a) Lobus parietalis +b) Somatosensoriskt centrum +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert data['question_type'] == 'textalternativ' + assert data['has_answer'] is True + assert 'Lobus parietalis' in data['correct_answer'] + assert 'Somatosensoriskt centrum' in data['correct_answer'] + + def test_parse_textfalt_question(self): + """Test text field (fill-in) question type""" + content = """--- +tags: [frĂ„getyp/textfĂ€lt, öga] +--- +**Fyll i rĂ€tt siffra!** + +a) Vilken siffra pekar pĂ„ gula flĂ€cken? +b) Vilken siffra pekar pĂ„ choroidea? + +```spoiler-block: +a) 7 +b) 6 +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert data['question_type'] == 'textfĂ€lt' + assert data['has_answer'] is True + assert '7' in data['correct_answer'] + assert '6' in data['correct_answer'] + + def test_skip_todo_answers(self): + """Test that questions with TODO are skipped""" + content = """--- +tags: [frĂ„getyp/mcq] +--- +What is this? + +- A: Option A +- B: Option B + +```spoiler-block: +TODO +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert data['has_answer'] is False + + def test_skip_non_question_files(self): + """Test that files without question tags are skipped""" + content = """--- +tags: [ah2, notes, general] +--- +This is just a note, not a question. +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is False + + def test_parse_with_images(self): + """Test parsing questions with embedded images""" + content = """--- +tags: [frĂ„getyp/scq, bild] +--- +![[image.png|338x258]] +Vilken bokstav pĂ„ denna bild sitter pĂ„ Mesencephalon? + +**VĂ€lj ett alternativ:** +- A +- B +- C +- D +- E +- F + +```spoiler-block: +F +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert 'Vilken bokstav' in data['text'] + assert data['correct_answer'] == 'F' + assert len(data['options']) == 6 + + def test_parse_yaml_list_format_tags(self): + """Test parsing tags in YAML list format""" + content = """--- +tags: + - ah2 + - provfrĂ„ga + - frĂ„getyp/scq + - anatomi +date: 2022-01-15 +--- +Question text? + +- A: Answer A +- B: Answer B + +```spoiler-block: +A +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert data['question_type'] == 'scq' + + def test_parse_mixed_option_formats(self): + """Test parsing with inconsistent option formatting""" + content = """--- +tags: [frĂ„getyp/mcq] +--- +Select correct options: + +**VĂ€lj tvĂ„ alternativ:** +- A: First option with text +- B:Second option no space +- C: Third option extra spaces +- D:Fourth with trailing + +```spoiler-block: +A och C +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert len(data['options']) == 4 + assert data['options'][0] == ('A', 'First option with text') + assert data['options'][1] == ('B', 'Second option no space') + assert data['correct_answer'] == 'A,C' + + def test_parse_question_with_multiple_paragraphs(self): + """Test question text extraction with multiple paragraphs""" + content = """--- +tags: [frĂ„getyp/scq] +--- +This is a longer question that spans multiple lines +and has additional context. + +**VĂ€lj ett alternativ:** +- A: Answer +- B: Another + +```spoiler-block: +A +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert 'This is a longer question' in data['text'] + + +@pytest.mark.django_db +@pytest.mark.import_tests +class TestQuestionImport: + """Test actual import of questions to database""" + + def test_import_single_question(self, tmp_path): + """Test importing a single question file""" + question_file = tmp_path / "question1.md" + question_file.write_text("""--- +tags: [frĂ„getyp/scq] +--- +Test question? + +- A: Correct +- B: Wrong + +```spoiler-block: +A +``` +""") + + stats = ImportStats() + result = import_question_file(question_file, tmp_path, stats, force=True) + + assert result in ['imported', 'updated'] + assert stats.questions_with_answers == 1 + assert stats.mcq_questions == 1 + + # Verify in database + question = Question.objects.get(text='Test question?') + assert question.correct_answer == 'A' + assert question.options.count() == 2 + + def test_import_multi_select_question(self, tmp_path): + """Test importing multi-select question""" + question_file = tmp_path / "question2.md" + question_file.write_text("""--- +tags: [frĂ„getyp/mcq] +--- +Multi-select question? + +- A: First correct +- B: Wrong +- C: Second correct + +```spoiler-block: +A och C +``` +""") + + stats = ImportStats() + import_question_file(question_file, tmp_path, stats, force=True) + + question = Question.objects.get(text='Multi-select question?') + assert question.correct_answer == 'A,C' + assert question.options.count() == 3 + + def test_skip_question_without_answer(self, tmp_path): + """Test that questions with TODO are not imported""" + question_file = tmp_path / "question3.md" + question_file.write_text("""--- +tags: [frĂ„getyp/scq] +--- +Incomplete question? + +- A: Option A +- B: Option B + +```spoiler-block: +TODO +``` +""") + + stats = ImportStats() + result = import_question_file(question_file, tmp_path, stats, force=True) + + assert result == 'skipped_todo' + assert stats.questions_with_todo == 1 + assert Question.objects.filter(text='Incomplete question?').count() == 0 + + def test_mtime_tracking(self, tmp_path): + """Test that file modification time is tracked""" + question_file = tmp_path / "question4.md" + question_file.write_text("""--- +tags: [frĂ„getyp/scq] +--- +What is the correct answer? + +**VĂ€lj ett alternativ:** +- A: Answer A +- B: Answer B + +```spoiler-block: +A +``` +""") + + stats = ImportStats() + result = import_question_file(question_file, tmp_path, stats, force=True) + + # Verify import succeeded + assert result in ['imported', 'updated'], f"Import failed with status: {result}" + assert stats.created == 1, f"Expected 1 created, got {stats.created}" + + question = Question.objects.get(text='What is the correct answer?') + assert question.file_mtime is not None + assert question.file_mtime == question_file.stat().st_mtime + + def test_update_existing_question(self, tmp_path): + """Test updating an existing question""" + question_file = tmp_path / "question5.md" + + # Initial import + question_file.write_text("""--- +tags: [frĂ„getyp/scq] +--- +What is the original question here? + +**VĂ€lj ett alternativ:** +- A: First answer +- B: Second answer + +```spoiler-block: +A +``` +""") + + stats1 = ImportStats() + result1 = import_question_file(question_file, tmp_path, stats1, force=True) + assert result1 in ['imported', 'updated'], f"Initial import failed: {result1}" + assert stats1.created == 1 + + # Update the file + import time + time.sleep(0.1) # Ensure mtime changes + question_file.write_text("""--- +tags: [frĂ„getyp/scq] +--- +What is the original question here? + +**VĂ€lj ett alternativ:** +- A: First answer +- B: Second answer +- C: Third option + +```spoiler-block: +C +``` +""") + + stats2 = ImportStats() + result = import_question_file(question_file, tmp_path, stats2, force=False) + + assert result == 'updated' + assert stats2.updated == 1 + + # Verify update + question = Question.objects.get(text='What is the original question here?') + assert question.correct_answer == 'C' + assert question.options.count() == 3 + + +@pytest.mark.django_db +@pytest.mark.import_tests +class TestImportStatistics: + """Test import statistics tracking""" + + def test_statistics_aggregation(self, tmp_path): + """Test that statistics are correctly aggregated""" + # Create multiple question files + (tmp_path / "folder1").mkdir() + (tmp_path / "folder2").mkdir() + + (tmp_path / "folder1" / "q1.md").write_text("""--- +tags: [frĂ„getyp/mcq] +--- +Question number one? + +**VĂ€lj tvĂ„ alternativ:** +- A: Answer A +- B: Answer B +```spoiler-block: +A +``` +""") + + (tmp_path / "folder1" / "q2.md").write_text("""--- +tags: [frĂ„getyp/scq] +--- +Question number two? + +**VĂ€lj ett alternativ:** +- A: Answer A +```spoiler-block: +TODO +``` +""") + + (tmp_path / "folder2" / "q3.md").write_text("""--- +tags: [notes] +--- +Not a question, just notes +""") + + from quiz.utils.importer import import_questions + stats = import_questions(tmp_path, tmp_path, force=True) + + assert stats.total_files == 3 + assert stats.mcq_questions == 2 + assert stats.questions_with_answers == 1 + assert stats.questions_with_todo == 1 + assert stats.non_mcq_skipped == 1 + + +@pytest.mark.django_db +class TestEdgeCases: + """Test edge cases and error handling""" + + def test_malformed_frontmatter(self): + """Test handling of malformed frontmatter""" + content = """--- +tags: [frĂ„getyp/scq] +date: broken +--- +Question? +- A: Answer +```spoiler-block: +A +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + # Should still parse as question if tags are recognizable + assert is_question is True + + def test_missing_spoiler_block(self): + """Test question without spoiler block""" + content = """--- +tags: [frĂ„getyp/scq] +--- +Question without answer? + +- A: Option A +- B: Option B +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert data['has_answer'] is False + + def test_empty_spoiler_block(self): + """Test question with empty spoiler block""" + content = """--- +tags: [frĂ„getyp/scq] +--- +Question with empty answer block? + +**VĂ€lj ett alternativ:** +- A: Option A + +```spoiler-block: +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert is_question is True + assert data.get('has_answer') is False + + def test_special_characters_in_text(self): + """Test handling of special characters""" + content = """--- +tags: [frĂ„getyp/scq] +--- +What about "quotes" & tags? + +- A: Option with ÄÀö +- B: Option with Ă©mojis 🎉 + +```spoiler-block: +A +``` +""" + is_question, data = parse_markdown_question(Path("test.md"), content) + + assert '"quotes"' in data['text'] + assert 'ÄÀö' in data['options'][0][1] +