vault backup: 2025-12-22 01:20:48
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m51s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m51s
This commit is contained in:
34
content/.obsidian/workspace.json
vendored
34
content/.obsidian/workspace.json
vendored
@@ -13,33 +13,31 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"type": "markdown",
|
"type": "markdown",
|
||||||
"state": {
|
"state": {
|
||||||
"file": "Anatomi & Histologi 2/Statistik.md",
|
"file": "Anatomi & Histologi 2/Gamla tentor/2023-05-31/8.md",
|
||||||
"mode": "source",
|
"mode": "source",
|
||||||
"source": false,
|
"source": true,
|
||||||
"backlinks": false
|
"backlinks": false
|
||||||
},
|
},
|
||||||
"icon": "lucide-file",
|
"icon": "lucide-file",
|
||||||
"title": "Statistik"
|
"title": "8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "a3f459e33e387813",
|
"id": "7e72057acf1e42f0",
|
||||||
"type": "tabs",
|
"type": "tabs",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "088f03735e333992",
|
"id": "c1c7815735aa906e",
|
||||||
"type": "leaf",
|
"type": "leaf",
|
||||||
"pinned": true,
|
|
||||||
"state": {
|
"state": {
|
||||||
"type": "pdf",
|
"type": "pdf",
|
||||||
"state": {
|
"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",
|
"icon": "lucide-file-text",
|
||||||
"title": "!2023-01-11-0044-PRX"
|
"title": "!2023-05-31-0100-DKS"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -213,9 +211,17 @@
|
|||||||
},
|
},
|
||||||
"active": "baa45c5e57825965",
|
"active": "baa45c5e57825965",
|
||||||
"lastOpenFiles": [
|
"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/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/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/29.md",
|
||||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/30.md",
|
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/30.md",
|
||||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/28.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/17.md",
|
||||||
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/16.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/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-06-01/!2022-06-01-0101-MGY.pdf",
|
||||||
"Anatomi & Histologi 2/Gamla tentor/2022-01-15/!2022-01-15-0032-BWD.pdf",
|
"Anatomi & Histologi 2/Gamla tentor/2022-01-15/!2022-01-15-0032-BWD.pdf",
|
||||||
"attachments/image-121.png",
|
"attachments/image-121.png",
|
||||||
@@ -258,7 +257,6 @@
|
|||||||
"attachments/image-112.png",
|
"attachments/image-112.png",
|
||||||
"Anatomi & Histologi 2/Gamla tentor/2025-06-03/!2025-06-03-0003-UJR.pdf",
|
"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-02-08/!2025-02-08-0003-ESW.pdf",
|
||||||
"Anatomi & Histologi 2/Gamla tentor/2025-01-15/!2025-01-15-0021-HRY.pdf",
|
|
||||||
"Untitled.canvas",
|
"Untitled.canvas",
|
||||||
"Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas",
|
"Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas",
|
||||||
"Biokemi/Metabolism/📋 Metabolismen översikt.canvas",
|
"Biokemi/Metabolism/📋 Metabolismen översikt.canvas",
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ Var sitter de nedre motorneuronens cellkroppar (ett- eller flera alternativ är
|
|||||||
- D: Cortex cerebri, lobus frontalis
|
- D: Cortex cerebri, lobus frontalis
|
||||||
|
|
||||||
```spoiler-block:
|
```spoiler-block:
|
||||||
TODO
|
A och B
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
tags: [ah2, provfråga, frågetyp/mcq, anatomi]
|
tags: [ah2, provfråga, frågetyp/scq, anatomi]
|
||||||
date: 2023-05-31
|
date: 2023-05-31
|
||||||
---
|
---
|
||||||
Vilken hjärnhinna innesluter cerebrospinalvätska och blodkärl? (1p)
|
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
|
- E: Dura mater
|
||||||
|
|
||||||
```spoiler-block:
|
```spoiler-block:
|
||||||
TODO
|
A
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -3,8 +3,13 @@ tags: [ah2, provfråga, frågetyp/matching, anatomi, öra]
|
|||||||
date: 2023-05-31
|
date: 2023-05-31
|
||||||
---
|
---
|
||||||
**Matcha rätt funktion med rätt lob:**
|
**Matcha rätt funktion med rätt lob:**
|
||||||
- (1p för alla rätt, inga delpoäng)
|
(1p för alla rätt, inga delpoäng)
|
||||||
- Smak Syn Somatosensorik Motorik Hörsel
|
- Smak
|
||||||
|
- Syn
|
||||||
|
- Somatosensorik
|
||||||
|
- Motorik
|
||||||
|
- Hörsel
|
||||||
|
|
||||||
- Lobus frontalis
|
- Lobus frontalis
|
||||||
- Lobus Insularis
|
- Lobus Insularis
|
||||||
- Lobus temporalis
|
- Lobus temporalis
|
||||||
@@ -12,5 +17,9 @@ date: 2023-05-31
|
|||||||
- Lobus occipitalis
|
- Lobus occipitalis
|
||||||
|
|
||||||
```spoiler-block:
|
```spoiler-block:
|
||||||
TODO
|
Smak: Lobus Insularis
|
||||||
|
Syn: Lobus occipitalis
|
||||||
|
Somatosensorik: Lobus parietalis
|
||||||
|
Motorik: Lobus frontalis
|
||||||
|
Hörsel: Lobus temporalis
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ date: 2023-05-31
|
|||||||
---
|
---
|
||||||
![[image-32.png]]
|
![[image-32.png]]
|
||||||
Vilken siffra/bokstav pekar på
|
Vilken siffra/bokstav pekar på
|
||||||
|
a) Corpus callosum (1..19)
|
||||||
a) Corpus callosum
|
b) Corpus pineale (1..19)
|
||||||
|
|
||||||
b) Corpus pineale
|
|
||||||
(0,5p per rätt svar, totalt 1p)
|
(0,5p per rätt svar, totalt 1p)
|
||||||
|
|
||||||
```spoiler-block:
|
```spoiler-block:
|
||||||
TODO
|
a) 2
|
||||||
|
b) 12
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -15,5 +15,5 @@ Om jag säger ”reglering av våra hormoner”
|
|||||||
- F: Hypofysen
|
- F: Hypofysen
|
||||||
|
|
||||||
```spoiler-block:
|
```spoiler-block:
|
||||||
TODO
|
D och F
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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)
|
- D: En del av limbiska systemet är hippocampus, som består av cortex cerebri (om än med egen histologi)
|
||||||
|
|
||||||
```spoiler-block:
|
```spoiler-block:
|
||||||
TODO
|
A och D
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,14 +2,13 @@
|
|||||||
tags: [ah2, provfråga, frågetyp/textalternativ, anatomi]
|
tags: [ah2, provfråga, frågetyp/textalternativ, anatomi]
|
||||||
date: 2023-05-31
|
date: 2023-05-31
|
||||||
---
|
---
|
||||||
Vilken del av hjärnstammen tar emot-
|
Vilken del av hjärnstammen tar emot-, kopplar om och skickar information vidare från cerebrum till cerebellum? (1p)
|
||||||
, kopplar om och skickar information vidare från cerebrum till cerebellum? (1p)
|
|
||||||
Främre delen av pons
|
|
||||||
Medulla oblongata).
|
|
||||||
- Pedunculus cerebellaris
|
- Pedunculus cerebellaris
|
||||||
- Främre delen av pons
|
- Främre delen av pons
|
||||||
- Mesencephalon
|
- Mesencephalon
|
||||||
|
- Medulla oblongata
|
||||||
|
|
||||||
```spoiler-block:
|
```spoiler-block:
|
||||||
TODO
|
Främre delen av pons
|
||||||
```
|
```
|
||||||
|
|||||||
Binary file not shown.
17
quiz/pytest.ini
Normal file
17
quiz/pytest.ini
Normal file
@@ -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
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
quiz/quiz/__pycache__/tests.cpython-314.pyc
Normal file
BIN
quiz/quiz/__pycache__/tests.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,34 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.utils.html import format_html
|
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):
|
class OptionInline(admin.TabularInline):
|
||||||
@@ -14,13 +42,13 @@ class OptionInline(admin.TabularInline):
|
|||||||
@admin.register(Question)
|
@admin.register(Question)
|
||||||
class QuestionAdmin(admin.ModelAdmin):
|
class QuestionAdmin(admin.ModelAdmin):
|
||||||
"""Admin interface for Questions"""
|
"""Admin interface for Questions"""
|
||||||
list_display = ['id', 'question_preview', 'correct_answer', 'option_count', 'file_source', 'updated_at']
|
list_display = ['id', 'question_preview', 'exam', 'correct_answer', 'option_count', 'file_source', 'updated_at']
|
||||||
list_filter = ['created_at', 'updated_at']
|
list_filter = ['exam__course', 'exam', 'created_at', 'updated_at']
|
||||||
search_fields = ['text', 'file_path', 'correct_answer']
|
search_fields = ['text', 'file_path', 'correct_answer']
|
||||||
readonly_fields = ['file_path', 'file_mtime', 'created_at', 'updated_at', 'formatted_mtime']
|
readonly_fields = ['file_path', 'file_mtime', 'created_at', 'updated_at', 'formatted_mtime']
|
||||||
fieldsets = [
|
fieldsets = [
|
||||||
('Question Content', {
|
('Question Content', {
|
||||||
'fields': ['text', 'correct_answer']
|
'fields': ['exam', 'text', 'correct_answer']
|
||||||
}),
|
}),
|
||||||
('File Tracking', {
|
('File Tracking', {
|
||||||
'fields': ['file_path', 'file_mtime', 'formatted_mtime'],
|
'fields': ['file_path', 'file_mtime', 'formatted_mtime'],
|
||||||
@@ -90,9 +118,9 @@ class OptionAdmin(admin.ModelAdmin):
|
|||||||
is_correct.short_description = 'Status'
|
is_correct.short_description = 'Status'
|
||||||
|
|
||||||
|
|
||||||
@admin.register(User)
|
@admin.register(QuizUser)
|
||||||
class UserAdmin(admin.ModelAdmin):
|
class QuizUserAdmin(admin.ModelAdmin):
|
||||||
"""Admin interface for Users"""
|
"""Admin interface for Quiz Users"""
|
||||||
list_display = ['id', 'session_preview', 'result_count', 'score_percentage', 'created_at']
|
list_display = ['id', 'session_preview', 'result_count', 'score_percentage', 'created_at']
|
||||||
list_filter = ['created_at']
|
list_filter = ['created_at']
|
||||||
search_fields = ['session_key']
|
search_fields = ['session_key']
|
||||||
@@ -121,9 +149,8 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
correct = obj.results.filter(is_correct=True).count()
|
correct = obj.results.filter(is_correct=True).count()
|
||||||
percentage = (correct / total * 100)
|
percentage = (correct / total * 100)
|
||||||
color = 'green' if percentage >= 70 else 'orange' if percentage >= 50 else 'red'
|
color = 'green' if percentage >= 70 else 'orange' if percentage >= 50 else 'red'
|
||||||
return format_html(
|
return mark_safe(
|
||||||
'<span style="color: {}; font-weight: bold;">{:.1f}%</span> ({}/{})',
|
f'<span style="color: {color}; font-weight: bold;">{percentage:.1f}%</span> ({correct}/{total})'
|
||||||
color, percentage, correct, total
|
|
||||||
)
|
)
|
||||||
score_percentage.short_description = 'Score'
|
score_percentage.short_description = 'Score'
|
||||||
|
|
||||||
@@ -159,7 +186,7 @@ class QuizResultAdmin(admin.ModelAdmin):
|
|||||||
def result_status(self, obj):
|
def result_status(self, obj):
|
||||||
"""Show visual result status"""
|
"""Show visual result status"""
|
||||||
if obj.is_correct:
|
if obj.is_correct:
|
||||||
return format_html('<span style="color: green; font-weight: bold;">✓ Correct</span>')
|
return mark_safe('<span style="color: green; font-weight: bold;">✓ Correct</span>')
|
||||||
return format_html('<span style="color: red; font-weight: bold;">✗ Wrong</span>')
|
return mark_safe('<span style="color: red; font-weight: bold;">✗ Wrong</span>')
|
||||||
result_status.short_description = 'Result'
|
result_status.short_description = 'Result'
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ class QuizAppConfig(AppConfig):
|
|||||||
Starts the auto-import watcher in a background thread.
|
Starts the auto-import watcher in a background thread.
|
||||||
"""
|
"""
|
||||||
# Only run in the main process (not in reloader process)
|
# 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_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
|
from quiz.utils.watcher import start_watcher_thread
|
||||||
start_watcher_thread()
|
start_watcher_thread()
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
87
quiz/quiz/management/commands/populate_exams.py
Normal file
87
quiz/quiz/management/commands/populate_exams.py
Normal file
@@ -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()}'
|
||||||
|
))
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from .models import User
|
from .models import QuizUser
|
||||||
|
|
||||||
|
|
||||||
class LazyAuthMiddleware:
|
class LazyAuthMiddleware:
|
||||||
@@ -14,8 +14,8 @@ class LazyAuthMiddleware:
|
|||||||
request.session.create()
|
request.session.create()
|
||||||
|
|
||||||
session_key = request.session.session_key
|
session_key = request.session.session_key
|
||||||
user, created = User.objects.get_or_create(session_key=session_key)
|
user, created = QuizUser.objects.get_or_create(session_key=session_key)
|
||||||
request.user = user
|
request.quiz_user = user
|
||||||
|
|
||||||
return self.get_response(request)
|
return self.get_response(request)
|
||||||
|
|
||||||
|
|||||||
@@ -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'},
|
||||||
|
),
|
||||||
|
]
|
||||||
44
quiz/quiz/migrations/0005_course_exam_question_exam.py
Normal file
44
quiz/quiz/migrations/0005_course_exam_question_exam.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
@@ -1,15 +1,45 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
class User(models.Model):
|
class QuizUser(models.Model):
|
||||||
session_key = models.CharField(max_length=40, unique=True)
|
session_key = models.CharField(max_length=40, unique=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Quiz User"
|
||||||
|
verbose_name_plural = "Quiz Users"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"User {self.session_key[:8]}"
|
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):
|
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)
|
file_path = models.CharField(max_length=500, unique=True)
|
||||||
text = models.TextField()
|
text = models.TextField()
|
||||||
correct_answer = models.CharField(max_length=50) # Support multi-select answers like "A,B,C"
|
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):
|
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)
|
question = models.ForeignKey(Question, on_delete=models.CASCADE)
|
||||||
selected_answer = models.CharField(max_length=1)
|
selected_answer = models.CharField(max_length=1)
|
||||||
is_correct = models.BooleanField()
|
is_correct = models.BooleanField()
|
||||||
|
|||||||
Binary file not shown.
@@ -131,8 +131,15 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
|
|||||||
question_text = line.strip().replace('**', '')
|
question_text = line.strip().replace('**', '')
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Return early if no question text found, but include has_answer field
|
||||||
if not question_text:
|
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)
|
# Extract options (pattern: "- A:" or "- A" for MCQ, or text for textalternativ)
|
||||||
options_data = []
|
options_data = []
|
||||||
@@ -184,11 +191,17 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
|
|||||||
options_data.append((letter, option_text))
|
options_data.append((letter, option_text))
|
||||||
|
|
||||||
# For text-based questions, options are optional
|
# For text-based questions, options are optional
|
||||||
if question_type in ['textfält'] and len(options_data) == 0:
|
if not options_data:
|
||||||
# Create a dummy option for text field questions
|
# At least return something for single-option questions
|
||||||
options_data = [('A', '')]
|
options_data = [('A', '')]
|
||||||
elif len(options_data) < 2 and question_type in ['mcq', 'scq']:
|
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
|
# Extract answer from spoiler block
|
||||||
correct_answer = None
|
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.questions_with_answers += 1
|
||||||
stats.by_folder[folder_name]['answered'] += 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
|
# Import to database with mtime tracking
|
||||||
question, created = Question.objects.update_or_create(
|
question, created = Question.objects.update_or_create(
|
||||||
file_path=file_path_str,
|
file_path=file_path_str,
|
||||||
defaults={
|
defaults={
|
||||||
|
'exam': exam,
|
||||||
'text': question_data['text'],
|
'text': question_data['text'],
|
||||||
'correct_answer': question_data['correct_answer'],
|
'correct_answer': question_data['correct_answer'],
|
||||||
'file_mtime': file_mtime, # Track modification time
|
'file_mtime': file_mtime, # Track modification time
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from .models import Question, QuizResult
|
|||||||
|
|
||||||
def index(request):
|
def index(request):
|
||||||
total_questions = Question.objects.count()
|
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 = {
|
context = {
|
||||||
'total_questions': total_questions,
|
'total_questions': total_questions,
|
||||||
@@ -17,7 +17,7 @@ def index(request):
|
|||||||
|
|
||||||
|
|
||||||
def get_next_question(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()
|
next_question = Question.objects.exclude(id__in=answered_ids).first()
|
||||||
|
|
||||||
if not next_question:
|
if not next_question:
|
||||||
@@ -42,7 +42,7 @@ def submit_answer(request):
|
|||||||
is_correct = selected_answer == question.correct_answer
|
is_correct = selected_answer == question.correct_answer
|
||||||
|
|
||||||
QuizResult.objects.update_or_create(
|
QuizResult.objects.update_or_create(
|
||||||
user=request.user,
|
user=request.quiz_user,
|
||||||
question=question,
|
question=question,
|
||||||
defaults={
|
defaults={
|
||||||
'selected_answer': selected_answer,
|
'selected_answer': selected_answer,
|
||||||
@@ -54,7 +54,7 @@ def submit_answer(request):
|
|||||||
|
|
||||||
|
|
||||||
def stats(request):
|
def stats(request):
|
||||||
results = QuizResult.objects.filter(user=request.user)
|
results = QuizResult.objects.filter(user=request.quiz_user)
|
||||||
total = results.count()
|
total = results.count()
|
||||||
correct = results.filter(is_correct=True).count()
|
correct = results.filter(is_correct=True).count()
|
||||||
|
|
||||||
|
|||||||
2
quiz/tests/__init__.py
Normal file
2
quiz/tests/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# This makes tests a package
|
||||||
|
|
||||||
BIN
quiz/tests/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
quiz/tests/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc
Normal file
BIN
quiz/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
quiz/tests/__pycache__/test_admin.cpython-314-pytest-9.0.2.pyc
Normal file
BIN
quiz/tests/__pycache__/test_admin.cpython-314-pytest-9.0.2.pyc
Normal file
Binary file not shown.
BIN
quiz/tests/__pycache__/test_import.cpython-314-pytest-9.0.2.pyc
Normal file
BIN
quiz/tests/__pycache__/test_import.cpython-314-pytest-9.0.2.pyc
Normal file
Binary file not shown.
108
quiz/tests/conftest.py
Normal file
108
quiz/tests/conftest.py
Normal file
@@ -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
|
||||||
|
```
|
||||||
|
"""
|
||||||
|
|
||||||
184
quiz/tests/test_admin.py
Normal file
184
quiz/tests/test_admin.py
Normal file
@@ -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}"
|
||||||
|
|
||||||
576
quiz/tests/test_import.py
Normal file
576
quiz/tests/test_import.py
Normal file
@@ -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" & <html> 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]
|
||||||
|
|
||||||
Reference in New Issue
Block a user