1
0

vault backup: 2025-12-21 20:21:58
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m30s

This commit is contained in:
2025-12-21 20:21:58 +01:00
parent ec61b89af6
commit 2ec904d899
132 changed files with 1283 additions and 233 deletions

2
quiz/quiz/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
default_app_config = 'quiz.apps.QuizAppConfig'

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

18
quiz/quiz/apps.py Normal file
View File

@@ -0,0 +1,18 @@
import os
from django.apps import AppConfig
class QuizAppConfig(AppConfig):
name = 'quiz'
default_auto_field = 'django.db.models.BigAutoField'
def ready(self):
"""
App initialization code.
Starts the auto-import watcher in a background thread.
"""
# Only run in the main process (not in reloader process)
if 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,2 @@
# Management commands directory

View File

@@ -0,0 +1,2 @@
# Management commands

View File

@@ -0,0 +1,37 @@
from django.core.management.base import BaseCommand
from django.conf import settings
from quiz.utils.importer import import_questions
class Command(BaseCommand):
help = 'Import questions from Markdown files'
def add_arguments(self, parser):
parser.add_argument(
'--folder',
type=str,
default='content/Anatomi & Histologi 2/Gamla tentor',
help='Folder to import questions from (relative to project root)'
)
def handle(self, *args, **options):
import_folder = options['folder']
folder = settings.BASE_DIR.parent / import_folder
if not folder.exists():
self.stdout.write(self.style.ERROR(f'Import folder {folder} does not exist'))
return
self.stdout.write(self.style.SUCCESS(f'Importing questions from {folder}...'))
stats = import_questions(folder, folder)
# Output statistics
self.stdout.write(stats.format_output())
if stats.errors > 0:
self.stdout.write(self.style.WARNING(f'Completed with {stats.errors} errors'))
else:
self.stdout.write(self.style.SUCCESS('Import completed successfully!'))

21
quiz/quiz/middleware.py Normal file
View File

@@ -0,0 +1,21 @@
from .models import User
class LazyAuthMiddleware:
"""
Middleware that automatically creates and authenticates users based on session key.
No login required - users are created transparently on first visit.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if not request.session.session_key:
request.session.create()
session_key = request.session.session_key
user, created = User.objects.get_or_create(session_key=session_key)
request.user = user
return self.get_response(request)

View File

@@ -0,0 +1,63 @@
# Generated by Django
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(max_length=40, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
),
migrations.CreateModel(
name='Question',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file_path', models.CharField(max_length=500, unique=True)),
('text', models.TextField()),
('correct_answer', models.CharField(max_length=1)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
),
migrations.CreateModel(
name='QuizResult',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('selected_answer', models.CharField(max_length=1)),
('is_correct', models.BooleanField()),
('answered_at', models.DateTimeField(auto_now_add=True)),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='quiz.question')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='quiz.user')),
],
),
migrations.CreateModel(
name='Option',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('letter', models.CharField(max_length=1)),
('text', models.TextField()),
('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='quiz.question')),
],
),
migrations.AlterUniqueTogether(
name='quizresult',
unique_together={('user', 'question')},
),
migrations.AlterUniqueTogether(
name='option',
unique_together={('question', 'letter')},
),
]

View File

@@ -0,0 +1 @@
# Migrations

47
quiz/quiz/models.py Normal file
View File

@@ -0,0 +1,47 @@
from django.db import models
class User(models.Model):
session_key = models.CharField(max_length=40, unique=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"User {self.session_key[:8]}"
class Question(models.Model):
file_path = models.CharField(max_length=500, unique=True)
text = models.TextField()
correct_answer = models.CharField(max_length=1)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return self.text[:50]
class Option(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='options')
letter = models.CharField(max_length=1)
text = models.TextField()
class Meta:
unique_together = ['question', 'letter']
def __str__(self):
return f"{self.letter}. {self.text[:30]}"
class QuizResult(models.Model):
user = models.ForeignKey(User, 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()
answered_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ['user', 'question']
def __str__(self):
return f"{self.user} - {self.question.text[:30]} - {'' if self.is_correct else ''}"

11
quiz/quiz/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path
from .views import index, get_next_question, submit_answer, stats
urlpatterns = [
path('', index, name='index'),
path('next/', get_next_question, name='next_question'),
path('submit/', submit_answer, name='submit_answer'),
path('stats/', stats, name='stats'),
]

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

248
quiz/quiz/utils/importer.py Normal file
View File

@@ -0,0 +1,248 @@
import re
from pathlib import Path
from collections import defaultdict
from typing import Tuple
from quiz.models import Question, Option
class ImportStats:
"""Track import statistics by exam folder"""
def __init__(self):
self.total_files = 0
self.mcq_questions = 0
self.non_mcq_skipped = 0
self.questions_with_answers = 0
self.questions_with_todo = 0
self.created = 0
self.updated = 0
self.errors = 0
self.by_folder = defaultdict(lambda: {
'total': 0,
'mcq': 0,
'answered': 0,
'todo': 0
})
def format_output(self) -> str:
"""Format statistics for console output"""
lines = []
lines.append("\n" + "="*70)
lines.append("QUESTION IMPORT STATISTICS")
lines.append("="*70)
lines.append(f"Total .md files found: {self.total_files}")
lines.append(f"MCQ questions found: {self.mcq_questions}")
lines.append(f"Non-MCQ skipped: {self.non_mcq_skipped}")
lines.append(f"Questions with answers: {self.questions_with_answers}")
lines.append(f"Questions with TODO: {self.questions_with_todo}")
lines.append(f"Created in database: {self.created}")
lines.append(f"Updated in database: {self.updated}")
if self.errors > 0:
lines.append(f"Errors: {self.errors}")
if self.mcq_questions > 0:
completion_pct = (self.questions_with_answers / self.mcq_questions * 100)
lines.append(f"Overall completion: {completion_pct:.1f}%")
lines.append("\n" + "-"*70)
lines.append("COMPLETION BY EXAM FOLDER")
lines.append("-"*70)
sorted_folders = sorted(self.by_folder.items())
for folder, stats in sorted_folders:
if stats['mcq'] > 0:
pct = (stats['answered'] / stats['mcq'] * 100)
lines.append(f"{folder:20} {stats['answered']:3}/{stats['mcq']:3} MCQ ({pct:5.1f}%)")
lines.append("="*70 + "\n")
return "\n".join(lines)
def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
"""
Parse a markdown file and extract question data.
Returns:
(is_mcq, question_data) where question_data contains:
- text: question text
- options: list of (letter, text) tuples
- correct_answer: the correct answer letter(s)
- has_answer: whether it has an answer (not TODO)
"""
lines = content.split('\n')
# Check for MCQ tags in frontmatter
is_mcq = False
in_frontmatter = False
for line in lines:
if line.strip() == '---':
in_frontmatter = not in_frontmatter
continue
if in_frontmatter and ('frågetyp/mcq' in line or 'frågetyp/scq' in line):
is_mcq = True
break
if not is_mcq:
return False, {}
# Extract question text (first non-empty line after frontmatter)
question_text = None
in_frontmatter = False
frontmatter_done = False
for line in lines:
if line.strip() == '---':
if not in_frontmatter:
in_frontmatter = True
else:
in_frontmatter = False
frontmatter_done = True
continue
if frontmatter_done and line.strip() and not line.startswith('![['):
# Skip "Välj ett/två alternativ:" lines
if 'Välj' in line and 'alternativ' in line:
continue
if not line.startswith('-') and not line.startswith('```'):
question_text = line.strip().replace('**', '')
break
if not question_text:
return True, {}
# Extract options (pattern: "- A:" or "- A" or just "- A:")
options_data = []
for line in lines:
# Match "- A: text" or "- A: " or just "- A"
match = re.match(r'^-\s*([A-Z]):\s*(.*)$', line.strip())
if not match:
# Also try "- A" without colon
match = re.match(r'^-\s*([A-Z])$', line.strip())
if match:
letter = match.group(1)
text = match.group(2) if len(match.groups()) > 1 else ""
options_data.append((letter, text.strip()))
if len(options_data) < 2:
return True, {}
# Extract answer from spoiler block
correct_answer = None
has_answer = False
in_spoiler = False
for line in lines:
if line.strip().startswith('```spoiler-block:'):
in_spoiler = True
continue
if in_spoiler:
if line.strip() == '```':
break
stripped = line.strip()
if stripped and stripped != 'TODO':
# Extract single letter answer (e.g., "B" or "F")
answer_match = re.match(r'^([A-Z])$', stripped)
if answer_match:
correct_answer = answer_match.group(1)
has_answer = True
break
elif stripped == 'TODO':
break
return True, {
'text': question_text,
'options': options_data,
'correct_answer': correct_answer,
'has_answer': has_answer
}
def import_question_file(file_path: Path, base_path: Path, stats: ImportStats):
"""Import a single question file"""
try:
content = file_path.read_text(encoding='utf-8')
is_mcq, question_data = parse_markdown_question(file_path, content)
# Track folder stats
relative_path = file_path.relative_to(base_path)
folder_name = relative_path.parts[0] if len(relative_path.parts) > 1 else 'root'
stats.by_folder[folder_name]['total'] += 1
if not is_mcq:
stats.non_mcq_skipped += 1
return
stats.mcq_questions += 1
stats.by_folder[folder_name]['mcq'] += 1
if not question_data or not question_data.get('text'):
stats.non_mcq_skipped += 1
return
if not question_data['has_answer']:
stats.questions_with_todo += 1
stats.by_folder[folder_name]['todo'] += 1
return # Skip questions without answers
stats.questions_with_answers += 1
stats.by_folder[folder_name]['answered'] += 1
# Import to database
file_path_str = str(file_path.relative_to(base_path.parent))
question, created = Question.objects.update_or_create(
file_path=file_path_str,
defaults={
'text': question_data['text'],
'correct_answer': question_data['correct_answer'],
}
)
if created:
stats.created += 1
else:
stats.updated += 1
# Update options
question.options.all().delete()
for letter, text in question_data['options']:
Option.objects.create(question=question, letter=letter, text=text)
except Exception as e:
stats.errors += 1
print(f"Error importing {file_path}: {e}")
def import_questions(folder_path: Path, base_path: Path = None) -> ImportStats:
"""
Import all questions from a folder.
Args:
folder_path: Path to the folder containing question markdown files
base_path: Base path for relative path calculations (defaults to folder_path)
Returns:
ImportStats object with import statistics
"""
if base_path is None:
base_path = folder_path
stats = ImportStats()
for md_file in folder_path.rglob('*.md'):
stats.total_files += 1
import_question_file(md_file, base_path, stats)
return stats
def delete_question_by_path(file_path: Path, base_path: Path):
"""Delete a question from the database by file path"""
try:
file_path_str = str(file_path.relative_to(base_path.parent))
Question.objects.filter(file_path=file_path_str).delete()
print(f"Deleted question: {file_path_str}")
except Exception as e:
print(f"Error deleting question {file_path}: {e}")

132
quiz/quiz/utils/watcher.py Normal file
View File

@@ -0,0 +1,132 @@
import time
import threading
from pathlib import Path
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent
from django.conf import settings
from quiz.utils.importer import import_question_file, delete_question_by_path, ImportStats
class QuestionFileHandler(FileSystemEventHandler):
"""Handle file system events for question markdown files"""
def __init__(self, base_path: Path, watch_path: Path):
super().__init__()
self.base_path = base_path
self.watch_path = watch_path
self.pending_events = {}
self.debounce_seconds = 2
self.lock = threading.Lock()
def _debounced_import(self, file_path: Path):
"""Import file after debounce delay"""
time.sleep(self.debounce_seconds)
with self.lock:
if file_path in self.pending_events:
del self.pending_events[file_path]
if file_path.exists():
print(f"\n[Auto-import] Processing: {file_path.name}")
stats = ImportStats()
import_question_file(file_path, self.watch_path, stats)
if stats.created > 0:
print(f"[Auto-import] ✓ Created question from {file_path.name}")
elif stats.updated > 0:
print(f"[Auto-import] ✓ Updated question from {file_path.name}")
elif stats.questions_with_todo > 0:
print(f"[Auto-import] ⊘ Skipped {file_path.name} (TODO answer)")
elif stats.non_mcq_skipped > 0:
print(f"[Auto-import] ⊘ Skipped {file_path.name} (not MCQ)")
def _handle_file_change(self, file_path: Path):
"""Handle file creation or modification with debouncing"""
if not file_path.suffix == '.md':
return
with self.lock:
# Cancel pending import if exists
if file_path in self.pending_events:
self.pending_events[file_path].cancel()
# Schedule new import
timer = threading.Timer(self.debounce_seconds, self._debounced_import, args=[file_path])
self.pending_events[file_path] = timer
timer.start()
def on_created(self, event: FileSystemEvent):
"""Handle file creation"""
if not event.is_directory:
self._handle_file_change(Path(event.src_path))
def on_modified(self, event: FileSystemEvent):
"""Handle file modification"""
if not event.is_directory:
self._handle_file_change(Path(event.src_path))
def on_deleted(self, event: FileSystemEvent):
"""Handle file deletion"""
if not event.is_directory and event.src_path.endswith('.md'):
file_path = Path(event.src_path)
print(f"\n[Auto-import] Deleting: {file_path.name}")
delete_question_by_path(file_path, self.watch_path)
class QuestionWatcher:
"""Watch for changes in question markdown files and auto-import"""
def __init__(self, watch_path: Path, base_path: Path = None):
self.watch_path = watch_path
self.base_path = base_path or watch_path
self.observer = None
self.running = False
def start(self):
"""Start watching for file changes"""
if self.running:
return
self.observer = Observer()
event_handler = QuestionFileHandler(self.base_path, self.watch_path)
self.observer.schedule(event_handler, str(self.watch_path), recursive=True)
self.observer.start()
self.running = True
print(f"[QuestionWatcher] Started watching: {self.watch_path}")
def stop(self):
"""Stop watching for file changes"""
if self.observer and self.running:
self.observer.stop()
self.observer.join()
self.running = False
print("[QuestionWatcher] Stopped")
def start_watcher_thread():
"""Start the question watcher in a background thread"""
from quiz.utils.importer import import_questions
def run_watcher():
# Get watch path from settings
watch_path_str = getattr(settings, 'QUESTION_WATCH_PATH', 'content/Anatomi & Histologi 2/Gamla tentor')
watch_path = settings.BASE_DIR.parent / watch_path_str
if not watch_path.exists():
print(f"[QuestionWatcher] Warning: Watch path does not exist: {watch_path}")
return
# Initial import
print("\n[QuestionWatcher] Starting initial import...")
stats = import_questions(watch_path, watch_path)
print(stats.format_output())
# Start watching for changes
watcher = QuestionWatcher(watch_path, watch_path)
watcher.start()
# Start in daemon thread so it doesn't block shutdown
thread = threading.Thread(target=run_watcher, name="QuestionWatcher", daemon=True)
thread.start()
print("[QuestionWatcher] Background thread started")

67
quiz/quiz/views.py Normal file
View File

@@ -0,0 +1,67 @@
from django.http import HttpResponse
from django.shortcuts import render
from django.views.decorators.http import require_http_methods
from .models import Question, QuizResult
def index(request):
total_questions = Question.objects.count()
answered_count = QuizResult.objects.filter(user=request.user).count()
context = {
'total_questions': total_questions,
'answered_count': answered_count,
}
return render(request, 'index.html', context)
def get_next_question(request):
answered_ids = QuizResult.objects.filter(user=request.user).values_list('question_id', flat=True)
next_question = Question.objects.exclude(id__in=answered_ids).first()
if not next_question:
return render(request, 'partials/complete.html')
return render(request, 'partials/question.html', {'question': next_question})
@require_http_methods(["POST"])
def submit_answer(request):
question_id = request.POST.get('question_id')
selected_answer = request.POST.get('answer')
if not question_id or not selected_answer:
return HttpResponse("Invalid submission", status=400)
try:
question = Question.objects.get(id=question_id)
except Question.DoesNotExist:
return HttpResponse("Question not found", status=404)
is_correct = selected_answer == question.correct_answer
QuizResult.objects.update_or_create(
user=request.user,
question=question,
defaults={
'selected_answer': selected_answer,
'is_correct': is_correct,
}
)
return get_next_question(request)
def stats(request):
results = QuizResult.objects.filter(user=request.user)
total = results.count()
correct = results.filter(is_correct=True).count()
context = {
'total': total,
'correct': correct,
'percentage': round((correct / total * 100) if total > 0 else 0, 1),
}
return render(request, 'stats.html', context)