1
0

vault backup: 2025-12-22 01:20:48
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m51s

This commit is contained in:
2025-12-22 01:20:48 +01:00
parent 9b90279c8a
commit 098dc9c8db
38 changed files with 1225 additions and 63 deletions

View File

@@ -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",

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -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
```

View File

@@ -15,5 +15,5 @@ Om jag säger ”reglering av våra hormoner”
- F: Hypofysen
```spoiler-block:
TODO
D och F
```

View File

@@ -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
```

View File

@@ -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
```

View File

Binary file not shown.

17
quiz/pytest.ini Normal file
View 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.

View File

@@ -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'

View File

@@ -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()

View 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()}'
))

View File

@@ -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)

View File

@@ -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'},
),
]

View 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'),
),
]

View File

@@ -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()

View File

@@ -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

View File

@@ -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
View File

@@ -0,0 +1,2 @@
# This makes tests a package

Binary file not shown.

108
quiz/tests/conftest.py Normal file
View 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
View 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
View 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]