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]
+