vault backup: 2025-12-21 20:21:58
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m30s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 2m30s
This commit is contained in:
143
quiz/IMPLEMENTATION_SUMMARY.md
Normal file
143
quiz/IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Auto-Import Implementation Summary
|
||||
|
||||
## What was implemented:
|
||||
|
||||
### 1. Dependencies (pyproject.toml)
|
||||
- Created `pyproject.toml` in `quiz/` directory
|
||||
- Added Django and watchdog dependencies
|
||||
- Configured for `uv` package manager
|
||||
|
||||
### 2. Question Importer (quiz/utils/importer.py)
|
||||
- `ImportStats` class: Tracks import statistics including:
|
||||
- Total files found
|
||||
- MCQ vs non-MCQ questions
|
||||
- Questions with answers vs TODO placeholders
|
||||
- Created vs updated counts
|
||||
- Per-folder completion statistics
|
||||
- `parse_markdown_question()`: Parses markdown files to extract:
|
||||
- Question text
|
||||
- Options (supports both `- A:` and `- A` formats)
|
||||
- Correct answer from spoiler blocks
|
||||
- Skips questions with "TODO" answers
|
||||
- `import_question_file()`: Imports a single question file
|
||||
- `import_questions()`: Bulk import with statistics
|
||||
- `delete_question_by_path()`: Removes deleted questions from database
|
||||
|
||||
### 3. File System Watcher (quiz/utils/watcher.py)
|
||||
- `QuestionFileHandler`: Handles file system events with:
|
||||
- 2-second debounce for file changes
|
||||
- Auto-import on file create/modify
|
||||
- Auto-delete on file removal
|
||||
- Real-time console feedback
|
||||
- `QuestionWatcher`: Main watcher class using watchdog Observer
|
||||
- `start_watcher_thread()`: Starts watcher in background daemon thread
|
||||
- Performs initial full import on startup
|
||||
- Displays comprehensive statistics
|
||||
- Continues watching for changes
|
||||
|
||||
### 4. Django Integration
|
||||
- Updated `QuizAppConfig.ready()` in `apps.py`:
|
||||
- Automatically starts watcher thread on Django startup
|
||||
- Runs in daemon thread (doesn't block shutdown)
|
||||
- Only runs in main process (not reloader)
|
||||
- Updated `import_questions` management command:
|
||||
- Uses refactored importer
|
||||
- Shows detailed statistics output
|
||||
- Added `QUESTION_WATCH_PATH` setting to `settings.py`
|
||||
|
||||
## Current Statistics:
|
||||
```
|
||||
Total .md files found: 312
|
||||
MCQ questions found: 162
|
||||
Non-MCQ skipped: 152
|
||||
Questions with answers: 6
|
||||
Questions with TODO: 154
|
||||
Overall completion: 3.7%
|
||||
|
||||
Completion by Exam Folder:
|
||||
2022-01-15 2/ 25 MCQ ( 8.0%)
|
||||
2022-06-01 4/ 19 MCQ ( 21.1%)
|
||||
2023-01-11 0/ 17 MCQ ( 0.0%)
|
||||
2023-05-31 0/ 10 MCQ ( 0.0%)
|
||||
2024-01-10 0/ 14 MCQ ( 0.0%)
|
||||
2024-05-29 0/ 14 MCQ ( 0.0%)
|
||||
2025-01-15 0/ 16 MCQ ( 0.0%)
|
||||
2025-02-08 0/ 16 MCQ ( 0.0%)
|
||||
2025-06-03 0/ 16 MCQ ( 0.0%)
|
||||
2025-08-08 0/ 15 MCQ ( 0.0%)
|
||||
```
|
||||
|
||||
## How it works:
|
||||
|
||||
1. **On Django startup**:
|
||||
- Background thread starts automatically
|
||||
- Performs initial import of all questions
|
||||
- Displays comprehensive statistics
|
||||
- Begins watching for file changes
|
||||
|
||||
2. **When you edit a question in Obsidian**:
|
||||
- Watcher detects file change
|
||||
- Waits 2 seconds (debounce multiple saves)
|
||||
- Automatically imports/updates the question
|
||||
- Shows console feedback
|
||||
|
||||
3. **When you delete a question file**:
|
||||
- Watcher detects deletion
|
||||
- Removes question from database
|
||||
|
||||
4. **Manual import**:
|
||||
- Run: `python3 manage.py import_questions`
|
||||
- Shows same detailed statistics
|
||||
|
||||
## Future considerations:
|
||||
|
||||
### Multi-select questions support:
|
||||
Currently the `Question.correct_answer` field is `max_length=1`, which only supports single answers. Many questions have "Välj två alternativ" and answers like "B och D" or "B, D".
|
||||
|
||||
To support multi-select:
|
||||
1. Update `Question.correct_answer` to `max_length=50`
|
||||
2. Create Django migration
|
||||
3. Update `parse_markdown_question()` to extract multiple letters (e.g., "B och D" → "B,D")
|
||||
4. Update `views.py` answer validation to compare sorted comma-separated values
|
||||
5. Update quiz UI to allow selecting multiple options
|
||||
|
||||
### Answer format normalization:
|
||||
Need to standardize multi-select answer format in Obsidian:
|
||||
- Current: "B och D", "B, D", "BD"
|
||||
- Recommended: "B,D" (sorted, comma-separated, no spaces)
|
||||
|
||||
### Question types not yet supported:
|
||||
- `frågetyp/hotspot`: Image-based clickable questions
|
||||
- `frågetyp/dnd-text`: Drag-and-drop text matching
|
||||
- `frågetyp/textfält`: Free text input questions
|
||||
- `frågetyp/sammansatt`: Multi-part questions
|
||||
|
||||
These are currently skipped during import.
|
||||
|
||||
## Testing the implementation:
|
||||
|
||||
1. **Start Django server**:
|
||||
```bash
|
||||
cd /Users/johandahlin/dev/medical-notes/quiz
|
||||
python3 manage.py runserver
|
||||
```
|
||||
You'll see the initial import statistics on startup.
|
||||
|
||||
2. **Test auto-import**:
|
||||
- Open a question in Obsidian with "TODO" in spoiler block
|
||||
- Replace "TODO" with a letter (e.g., "B")
|
||||
- Save the file
|
||||
- Check Django console for auto-import message
|
||||
|
||||
3. **Test manual import**:
|
||||
```bash
|
||||
python3 manage.py import_questions
|
||||
```
|
||||
|
||||
4. **Check database**:
|
||||
```bash
|
||||
python3 manage.py shell
|
||||
>>> from quiz.models import Question
|
||||
>>> Question.objects.count()
|
||||
```
|
||||
|
||||
BIN
quiz/__pycache__/settings.cpython-314.pyc
Normal file
BIN
quiz/__pycache__/settings.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/db.sqlite3
Normal file
BIN
quiz/db.sqlite3
Normal file
Binary file not shown.
BIN
quiz/db.sqlite3-shm
Normal file
BIN
quiz/db.sqlite3-shm
Normal file
Binary file not shown.
BIN
quiz/db.sqlite3-wal
Normal file
BIN
quiz/db.sqlite3-wal
Normal file
Binary file not shown.
23
quiz/manage.py
Executable file
23
quiz/manage.py
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
17
quiz/pyproject.toml
Normal file
17
quiz/pyproject.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[project]
|
||||
name = "quiz"
|
||||
version = "0.1.0"
|
||||
description = "Medical quiz application with auto-import from Obsidian"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"django>=6.0.0",
|
||||
"watchdog>=6.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = []
|
||||
|
||||
2
quiz/quiz/__init__.py
Normal file
2
quiz/quiz/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
default_app_config = 'quiz.apps.QuizAppConfig'
|
||||
|
||||
BIN
quiz/quiz/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
quiz/quiz/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/quiz/__pycache__/apps.cpython-314.pyc
Normal file
BIN
quiz/quiz/__pycache__/apps.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/quiz/__pycache__/middleware.cpython-314.pyc
Normal file
BIN
quiz/quiz/__pycache__/middleware.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/quiz/__pycache__/models.cpython-314.pyc
Normal file
BIN
quiz/quiz/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/quiz/__pycache__/urls.cpython-314.pyc
Normal file
BIN
quiz/quiz/__pycache__/urls.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/quiz/__pycache__/views.cpython-314.pyc
Normal file
BIN
quiz/quiz/__pycache__/views.cpython-314.pyc
Normal file
Binary file not shown.
18
quiz/quiz/apps.py
Normal file
18
quiz/quiz/apps.py
Normal 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()
|
||||
|
||||
2
quiz/quiz/management/__init__.py
Normal file
2
quiz/quiz/management/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Management commands directory
|
||||
|
||||
BIN
quiz/quiz/management/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
quiz/quiz/management/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
2
quiz/quiz/management/commands/__init__.py
Normal file
2
quiz/quiz/management/commands/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Management commands
|
||||
|
||||
Binary file not shown.
Binary file not shown.
37
quiz/quiz/management/commands/import_questions.py
Normal file
37
quiz/quiz/management/commands/import_questions.py
Normal 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
21
quiz/quiz/middleware.py
Normal 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)
|
||||
|
||||
63
quiz/quiz/migrations/0001_initial.py
Normal file
63
quiz/quiz/migrations/0001_initial.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
|
||||
1
quiz/quiz/migrations/__init__.py
Normal file
1
quiz/quiz/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Migrations
|
||||
BIN
quiz/quiz/migrations/__pycache__/0001_initial.cpython-314.pyc
Normal file
BIN
quiz/quiz/migrations/__pycache__/0001_initial.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/quiz/migrations/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
quiz/quiz/migrations/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
47
quiz/quiz/models.py
Normal file
47
quiz/quiz/models.py
Normal 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
11
quiz/quiz/urls.py
Normal 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'),
|
||||
]
|
||||
|
||||
0
quiz/quiz/utils/__init__.py
Normal file
0
quiz/quiz/utils/__init__.py
Normal file
BIN
quiz/quiz/utils/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
quiz/quiz/utils/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/quiz/utils/__pycache__/importer.cpython-314.pyc
Normal file
BIN
quiz/quiz/utils/__pycache__/importer.cpython-314.pyc
Normal file
Binary file not shown.
BIN
quiz/quiz/utils/__pycache__/watcher.cpython-314.pyc
Normal file
BIN
quiz/quiz/utils/__pycache__/watcher.cpython-314.pyc
Normal file
Binary file not shown.
248
quiz/quiz/utils/importer.py
Normal file
248
quiz/quiz/utils/importer.py
Normal 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
132
quiz/quiz/utils/watcher.py
Normal 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
67
quiz/quiz/views.py
Normal 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)
|
||||
|
||||
75
quiz/settings.py
Normal file
75
quiz/settings.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Django settings for quiz application
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-change-this-in-production')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.environ.get('DEBUG', 'True') == 'True'
|
||||
|
||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.staticfiles',
|
||||
'quiz.apps.QuizAppConfig',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'quiz.middleware.LazyAuthMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'quiz.urls'
|
||||
|
||||
TEMPLATES = [{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
}]
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
'OPTIONS': {
|
||||
'init_command': "PRAGMA journal_mode=WAL;",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/stable/howto/static-files/
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'static'
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/stable/ref/settings/#default-auto-field
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Internationalization
|
||||
USE_TZ = True
|
||||
|
||||
# Question import settings
|
||||
QUESTION_WATCH_PATH = 'content/Anatomi & Histologi 2/Gamla tentor'
|
||||
|
||||
21
quiz/templates/base.html
Normal file
21
quiz/templates/base.html
Normal file
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="sv">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Quiz</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
|
||||
.question { background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0; }
|
||||
.option { padding: 10px; margin: 5px 0; cursor: pointer; border: 2px solid #ddd; border-radius: 4px; }
|
||||
.option:hover { background: #e9e9e9; }
|
||||
.progress { background: #ddd; height: 20px; border-radius: 10px; margin: 20px 0; }
|
||||
.progress-bar { background: #4CAF50; height: 100%; border-radius: 10px; transition: width 0.3s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% block content %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
10
quiz/templates/index.html
Normal file
10
quiz/templates/index.html
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Quiz Application</h1>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" style="width: {% if total_questions > 0 %}{{ answered_count|floatformat:0 }}{% else %}0{% endif %}%"></div>
|
||||
</div>
|
||||
<p>Besvarade frågor: {{ answered_count }} / {{ total_questions }}</p>
|
||||
<div id="quiz-container" hx-get="{% url 'next_question' %}" hx-trigger="load"></div>
|
||||
{% endblock %}
|
||||
|
||||
6
quiz/templates/partials/complete.html
Normal file
6
quiz/templates/partials/complete.html
Normal file
@@ -0,0 +1,6 @@
|
||||
<div class="question">
|
||||
<h2>Quiz Completed!</h2>
|
||||
<p>Du har besvarat alla frågor.</p>
|
||||
<a href="{% url 'stats' %}">Se dina resultat</a>
|
||||
</div>
|
||||
|
||||
14
quiz/templates/partials/question.html
Normal file
14
quiz/templates/partials/question.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<div class="question">
|
||||
<h2>{{ question.text }}</h2>
|
||||
<form hx-post="{% url 'submit_answer' %}" hx-target="#quiz-container">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="question_id" value="{{ question.id }}">
|
||||
{% for option in question.options.all %}
|
||||
<div class="option" onclick="this.querySelector('input').checked = true; this.closest('form').requestSubmit();">
|
||||
<input type="radio" name="answer" value="{{ option.letter }}" id="opt_{{ option.letter }}" style="display:none;">
|
||||
<label for="opt_{{ option.letter }}">{{ option.letter }}. {{ option.text }}</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
11
quiz/templates/stats.html
Normal file
11
quiz/templates/stats.html
Normal file
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Dina Resultat</h1>
|
||||
<div class="question">
|
||||
<p>Totalt besvarade: {{ total }}</p>
|
||||
<p>Rätt svar: {{ correct }}</p>
|
||||
<p>Procent: {{ percentage }}%</p>
|
||||
</div>
|
||||
<a href="{% url 'index' %}">Tillbaka till quiz</a>
|
||||
{% endblock %}
|
||||
|
||||
17
quiz/wsgi.py
Normal file
17
quiz/wsgi.py
Normal file
@@ -0,0 +1,17 @@
|
||||
"""
|
||||
WSGI config for quiz project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/stable/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
|
||||
Reference in New Issue
Block a user