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": {
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -15,5 +15,5 @@ Om jag säger ”reglering av våra hormoner”
|
||||
- F: Hypofysen
|
||||
|
||||
```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)
|
||||
|
||||
```spoiler-block:
|
||||
TODO
|
||||
A och D
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
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.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(
|
||||
'<span style="color: {}; font-weight: bold;">{:.1f}%</span> ({}/{})',
|
||||
color, percentage, correct, total
|
||||
return mark_safe(
|
||||
f'<span style="color: {color}; font-weight: bold;">{percentage:.1f}%</span> ({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('<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: green; font-weight: bold;">✓ Correct</span>')
|
||||
return mark_safe('<span style="color: red; font-weight: bold;">✗ Wrong</span>')
|
||||
result_status.short_description = 'Result'
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
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:
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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('**', '')
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
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