vault backup: 2025-12-21 20:42:49
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 3m33s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 3m33s
This commit is contained in:
0
quiz/ADMIN_README.md
Normal file
0
quiz/ADMIN_README.md
Normal file
@@ -1,143 +0,0 @@
|
|||||||
# 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()
|
|
||||||
```
|
|
||||||
|
|
||||||
Binary file not shown.
BIN
quiz/db.sqlite3
BIN
quiz/db.sqlite3
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
quiz/quiz/__pycache__/admin.cpython-314.pyc
Normal file
BIN
quiz/quiz/__pycache__/admin.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
165
quiz/quiz/admin.py
Normal file
165
quiz/quiz/admin.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
from .models import User, Question, Option, QuizResult
|
||||||
|
|
||||||
|
|
||||||
|
class OptionInline(admin.TabularInline):
|
||||||
|
"""Inline admin for question options"""
|
||||||
|
model = Option
|
||||||
|
extra = 0
|
||||||
|
fields = ['letter', 'text']
|
||||||
|
ordering = ['letter']
|
||||||
|
|
||||||
|
|
||||||
|
@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']
|
||||||
|
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']
|
||||||
|
}),
|
||||||
|
('File Tracking', {
|
||||||
|
'fields': ['file_path', 'file_mtime', 'formatted_mtime'],
|
||||||
|
'classes': ['collapse']
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ['created_at', 'updated_at'],
|
||||||
|
'classes': ['collapse']
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
inlines = [OptionInline]
|
||||||
|
|
||||||
|
def question_preview(self, obj):
|
||||||
|
"""Show question text preview"""
|
||||||
|
return obj.text[:60] + '...' if len(obj.text) > 60 else obj.text
|
||||||
|
question_preview.short_description = 'Question'
|
||||||
|
|
||||||
|
def option_count(self, obj):
|
||||||
|
"""Show number of options"""
|
||||||
|
return obj.options.count()
|
||||||
|
option_count.short_description = '# Options'
|
||||||
|
|
||||||
|
def file_source(self, obj):
|
||||||
|
"""Show file path with folder highlight"""
|
||||||
|
if obj.file_path:
|
||||||
|
parts = obj.file_path.split('/')
|
||||||
|
if len(parts) > 1:
|
||||||
|
folder = parts[-2]
|
||||||
|
filename = parts[-1]
|
||||||
|
return format_html('<span style="color: #666;">{}/</span><strong>{}</strong>', folder, filename)
|
||||||
|
return obj.file_path or '-'
|
||||||
|
file_source.short_description = 'Source File'
|
||||||
|
|
||||||
|
def formatted_mtime(self, obj):
|
||||||
|
"""Show formatted modification time"""
|
||||||
|
if obj.file_mtime:
|
||||||
|
from datetime import datetime
|
||||||
|
dt = datetime.fromtimestamp(obj.file_mtime)
|
||||||
|
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
return '-'
|
||||||
|
formatted_mtime.short_description = 'File Modified'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Option)
|
||||||
|
class OptionAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for Options"""
|
||||||
|
list_display = ['id', 'question_preview', 'letter', 'text_preview', 'is_correct']
|
||||||
|
list_filter = ['letter']
|
||||||
|
search_fields = ['text', 'question__text']
|
||||||
|
readonly_fields = ['question']
|
||||||
|
|
||||||
|
def question_preview(self, obj):
|
||||||
|
"""Show question preview"""
|
||||||
|
return obj.question.text[:40] + '...'
|
||||||
|
question_preview.short_description = 'Question'
|
||||||
|
|
||||||
|
def text_preview(self, obj):
|
||||||
|
"""Show option text preview"""
|
||||||
|
return obj.text[:50] + '...' if len(obj.text) > 50 else obj.text
|
||||||
|
text_preview.short_description = 'Option Text'
|
||||||
|
|
||||||
|
def is_correct(self, obj):
|
||||||
|
"""Highlight if this is the correct answer"""
|
||||||
|
if obj.question.correct_answer and obj.letter in obj.question.correct_answer:
|
||||||
|
return format_html('<span style="color: green; font-weight: bold;">✓ Correct</span>')
|
||||||
|
return format_html('<span style="color: #999;">-</span>')
|
||||||
|
is_correct.short_description = 'Status'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(User)
|
||||||
|
class UserAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for Users"""
|
||||||
|
list_display = ['id', 'session_preview', 'result_count', 'score_percentage', 'created_at']
|
||||||
|
list_filter = ['created_at']
|
||||||
|
search_fields = ['session_key']
|
||||||
|
readonly_fields = ['session_key', 'created_at', 'full_session_key']
|
||||||
|
fieldsets = [
|
||||||
|
('User Info', {
|
||||||
|
'fields': ['full_session_key', 'created_at']
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
def session_preview(self, obj):
|
||||||
|
"""Show session key preview"""
|
||||||
|
return f"{obj.session_key[:12]}..."
|
||||||
|
session_preview.short_description = 'Session'
|
||||||
|
|
||||||
|
def result_count(self, obj):
|
||||||
|
"""Show number of quiz results"""
|
||||||
|
return obj.results.count()
|
||||||
|
result_count.short_description = '# Answers'
|
||||||
|
|
||||||
|
def score_percentage(self, obj):
|
||||||
|
"""Show score percentage"""
|
||||||
|
total = obj.results.count()
|
||||||
|
if total == 0:
|
||||||
|
return '-'
|
||||||
|
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
|
||||||
|
)
|
||||||
|
score_percentage.short_description = 'Score'
|
||||||
|
|
||||||
|
def full_session_key(self, obj):
|
||||||
|
"""Show full session key"""
|
||||||
|
return obj.session_key
|
||||||
|
full_session_key.short_description = 'Full Session Key'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(QuizResult)
|
||||||
|
class QuizResultAdmin(admin.ModelAdmin):
|
||||||
|
"""Admin interface for Quiz Results"""
|
||||||
|
list_display = ['id', 'user_preview', 'question_preview', 'selected_answer', 'correct_answer', 'result_status', 'answered_at']
|
||||||
|
list_filter = ['is_correct', 'answered_at']
|
||||||
|
search_fields = ['user__session_key', 'question__text']
|
||||||
|
readonly_fields = ['user', 'question', 'selected_answer', 'is_correct', 'answered_at']
|
||||||
|
|
||||||
|
def user_preview(self, obj):
|
||||||
|
"""Show user session preview"""
|
||||||
|
return f"{obj.user.session_key[:8]}..."
|
||||||
|
user_preview.short_description = 'User'
|
||||||
|
|
||||||
|
def question_preview(self, obj):
|
||||||
|
"""Show question preview"""
|
||||||
|
return obj.question.text[:40] + '...'
|
||||||
|
question_preview.short_description = 'Question'
|
||||||
|
|
||||||
|
def correct_answer(self, obj):
|
||||||
|
"""Show correct answer"""
|
||||||
|
return obj.question.correct_answer
|
||||||
|
correct_answer.short_description = 'Correct'
|
||||||
|
|
||||||
|
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>')
|
||||||
|
result_status.short_description = 'Result'
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
@@ -12,7 +13,10 @@ class QuizAppConfig(AppConfig):
|
|||||||
Starts the auto-import watcher in a background thread.
|
Starts the auto-import watcher in a background thread.
|
||||||
"""
|
"""
|
||||||
# Only run in the main process (not in reloader process)
|
# Only run in the main process (not in reloader process)
|
||||||
if os.environ.get('RUN_MAIN') == 'true' or os.environ.get('RUN_MAIN') is None:
|
# And not during management commands (to avoid database locking)
|
||||||
|
is_management_command = any(cmd in sys.argv for cmd in ['import_questions', 'migrate', 'makemigrations', 'shell'])
|
||||||
|
|
||||||
|
if not is_management_command and (os.environ.get('RUN_MAIN') == 'true' or os.environ.get('RUN_MAIN') is None):
|
||||||
from quiz.utils.watcher import start_watcher_thread
|
from quiz.utils.watcher import start_watcher_thread
|
||||||
start_watcher_thread()
|
start_watcher_thread()
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -26,12 +26,16 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
stats = import_questions(folder, folder)
|
stats = import_questions(folder, folder)
|
||||||
|
|
||||||
# Output statistics
|
# Only show full statistics if there were changes
|
||||||
self.stdout.write(stats.format_output())
|
output = stats.format_output(show_if_no_changes=False)
|
||||||
|
if output:
|
||||||
if stats.errors > 0:
|
self.stdout.write(output)
|
||||||
self.stdout.write(self.style.WARNING(f'Completed with {stats.errors} errors'))
|
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!'))
|
||||||
else:
|
else:
|
||||||
self.stdout.write(self.style.SUCCESS('Import completed successfully!'))
|
# No changes, show brief message
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'✓ All {stats.total_files} files up to date, no changes needed'))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
18
quiz/quiz/migrations/0002_alter_question_correct_answer.py
Normal file
18
quiz/quiz/migrations/0002_alter_question_correct_answer.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-21 19:25
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('quiz', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='question',
|
||||||
|
name='correct_answer',
|
||||||
|
field=models.CharField(max_length=50),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
quiz/quiz/migrations/0003_question_file_mtime.py
Normal file
18
quiz/quiz/migrations/0003_question_file_mtime.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2025-12-21 19:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('quiz', '0002_alter_question_correct_answer'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='question',
|
||||||
|
name='file_mtime',
|
||||||
|
field=models.FloatField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
Binary file not shown.
Binary file not shown.
@@ -12,7 +12,8 @@ class User(models.Model):
|
|||||||
class Question(models.Model):
|
class Question(models.Model):
|
||||||
file_path = models.CharField(max_length=500, unique=True)
|
file_path = models.CharField(max_length=500, unique=True)
|
||||||
text = models.TextField()
|
text = models.TextField()
|
||||||
correct_answer = models.CharField(max_length=1)
|
correct_answer = models.CharField(max_length=50) # Support multi-select answers like "A,B,C"
|
||||||
|
file_mtime = models.FloatField(null=True, blank=True) # Track file modification time
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import index, get_next_question, submit_answer, stats
|
from .views import index, get_next_question, submit_answer, stats
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
path('', index, name='index'),
|
path('', index, name='index'),
|
||||||
path('next/', get_next_question, name='next_question'),
|
path('next/', get_next_question, name='next_question'),
|
||||||
path('submit/', submit_answer, name='submit_answer'),
|
path('submit/', submit_answer, name='submit_answer'),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -24,8 +24,20 @@ class ImportStats:
|
|||||||
'todo': 0
|
'todo': 0
|
||||||
})
|
})
|
||||||
|
|
||||||
def format_output(self) -> str:
|
def has_changes(self) -> bool:
|
||||||
"""Format statistics for console output"""
|
"""Check if there were any actual changes"""
|
||||||
|
return self.created > 0 or self.updated > 0 or self.errors > 0
|
||||||
|
|
||||||
|
def format_output(self, show_if_no_changes: bool = True) -> str:
|
||||||
|
"""
|
||||||
|
Format statistics for console output
|
||||||
|
|
||||||
|
Args:
|
||||||
|
show_if_no_changes: If False, returns empty string when no changes
|
||||||
|
"""
|
||||||
|
if not show_if_no_changes and not self.has_changes():
|
||||||
|
return ""
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
lines.append("\n" + "="*70)
|
lines.append("\n" + "="*70)
|
||||||
lines.append("QUESTION IMPORT STATISTICS")
|
lines.append("QUESTION IMPORT STATISTICS")
|
||||||
@@ -71,18 +83,30 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
|
|||||||
"""
|
"""
|
||||||
lines = content.split('\n')
|
lines = content.split('\n')
|
||||||
|
|
||||||
# Check for MCQ tags in frontmatter
|
# Check for question tags in frontmatter
|
||||||
is_mcq = False
|
# Accept: frågetyp/mcq, frågetyp/scq, frågetyp/textalternativ, frågetyp/textfält
|
||||||
|
is_question = False
|
||||||
|
question_type = None
|
||||||
in_frontmatter = False
|
in_frontmatter = False
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if line.strip() == '---':
|
if line.strip() == '---':
|
||||||
in_frontmatter = not in_frontmatter
|
in_frontmatter = not in_frontmatter
|
||||||
continue
|
continue
|
||||||
if in_frontmatter and ('frågetyp/mcq' in line or 'frågetyp/scq' in line):
|
if in_frontmatter and 'frågetyp/' in line:
|
||||||
is_mcq = True
|
is_question = True
|
||||||
|
# Extract question type
|
||||||
|
if 'frågetyp/mcq' in line:
|
||||||
|
question_type = 'mcq'
|
||||||
|
elif 'frågetyp/scq' in line:
|
||||||
|
question_type = 'scq'
|
||||||
|
elif 'frågetyp/textalternativ' in line:
|
||||||
|
question_type = 'textalternativ'
|
||||||
|
elif 'frågetyp/textfält' in line:
|
||||||
|
question_type = 'textfält'
|
||||||
break
|
break
|
||||||
|
|
||||||
if not is_mcq:
|
if not is_question:
|
||||||
return False, {}
|
return False, {}
|
||||||
|
|
||||||
# Extract question text (first non-empty line after frontmatter)
|
# Extract question text (first non-empty line after frontmatter)
|
||||||
@@ -110,9 +134,34 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
|
|||||||
if not question_text:
|
if not question_text:
|
||||||
return True, {}
|
return True, {}
|
||||||
|
|
||||||
# Extract options (pattern: "- A:" or "- A" or just "- A:")
|
# Extract options (pattern: "- A:" or "- A" for MCQ, or text for textalternativ)
|
||||||
options_data = []
|
options_data = []
|
||||||
|
in_frontmatter = False
|
||||||
|
frontmatter_done = False
|
||||||
|
in_spoiler = False
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
# Track frontmatter to skip it
|
||||||
|
if line.strip() == '---':
|
||||||
|
if not in_frontmatter:
|
||||||
|
in_frontmatter = True
|
||||||
|
else:
|
||||||
|
in_frontmatter = False
|
||||||
|
frontmatter_done = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip frontmatter and spoiler blocks
|
||||||
|
if in_frontmatter or not frontmatter_done:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if line.strip().startswith('```spoiler-block:'):
|
||||||
|
in_spoiler = True
|
||||||
|
continue
|
||||||
|
if in_spoiler:
|
||||||
|
if line.strip() == '```':
|
||||||
|
in_spoiler = False
|
||||||
|
continue
|
||||||
|
|
||||||
# Match "- A: text" or "- A: " or just "- A"
|
# Match "- A: text" or "- A: " or just "- A"
|
||||||
match = re.match(r'^-\s*([A-Z]):\s*(.*)$', line.strip())
|
match = re.match(r'^-\s*([A-Z]):\s*(.*)$', line.strip())
|
||||||
if not match:
|
if not match:
|
||||||
@@ -123,14 +172,29 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
|
|||||||
letter = match.group(1)
|
letter = match.group(1)
|
||||||
text = match.group(2) if len(match.groups()) > 1 else ""
|
text = match.group(2) if len(match.groups()) > 1 else ""
|
||||||
options_data.append((letter, text.strip()))
|
options_data.append((letter, text.strip()))
|
||||||
|
else:
|
||||||
|
# For textalternativ, options might be plain text items
|
||||||
|
if question_type in ['textalternativ', 'textfält'] and line.strip().startswith('-') and not line.strip().startswith('--'):
|
||||||
|
# Extract text after dash
|
||||||
|
option_text = line.strip()[1:].strip()
|
||||||
|
# Skip if it's a sub-question marker like "a)" or "b)"
|
||||||
|
if option_text and not re.match(r'^[a-z]\)', option_text):
|
||||||
|
# Use incrementing letters for text options
|
||||||
|
letter = chr(ord('A') + len(options_data))
|
||||||
|
options_data.append((letter, option_text))
|
||||||
|
|
||||||
if len(options_data) < 2:
|
# 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
|
||||||
|
options_data = [('A', '')]
|
||||||
|
elif len(options_data) < 2 and question_type in ['mcq', 'scq']:
|
||||||
return True, {}
|
return True, {}
|
||||||
|
|
||||||
# Extract answer from spoiler block
|
# Extract answer from spoiler block
|
||||||
correct_answer = None
|
correct_answer = None
|
||||||
has_answer = False
|
has_answer = False
|
||||||
in_spoiler = False
|
in_spoiler = False
|
||||||
|
answer_lines = []
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if line.strip().startswith('```spoiler-block:'):
|
if line.strip().startswith('```spoiler-block:'):
|
||||||
@@ -140,27 +204,69 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
|
|||||||
if line.strip() == '```':
|
if line.strip() == '```':
|
||||||
break
|
break
|
||||||
stripped = line.strip()
|
stripped = line.strip()
|
||||||
if stripped and stripped != 'TODO':
|
if stripped:
|
||||||
# Extract single letter answer (e.g., "B" or "F")
|
answer_lines.append(stripped)
|
||||||
answer_match = re.match(r'^([A-Z])$', stripped)
|
|
||||||
if answer_match:
|
# Process collected answer lines
|
||||||
correct_answer = answer_match.group(1)
|
if answer_lines:
|
||||||
has_answer = True
|
full_answer = ' '.join(answer_lines)
|
||||||
break
|
|
||||||
elif stripped == 'TODO':
|
# Check for TODO
|
||||||
break
|
if 'TODO' in full_answer.upper():
|
||||||
|
has_answer = False
|
||||||
|
else:
|
||||||
|
has_answer = True
|
||||||
|
|
||||||
|
# For MCQ/SCQ: Extract capital letters
|
||||||
|
if question_type in ['mcq', 'scq']:
|
||||||
|
letters = re.findall(r'\b([A-Z])\b', full_answer)
|
||||||
|
if letters:
|
||||||
|
correct_answer = ','.join(sorted(set(letters)))
|
||||||
|
else:
|
||||||
|
# For text-based questions: Store the full answer text
|
||||||
|
correct_answer = full_answer[:200] # Limit to 200 chars for database field
|
||||||
|
|
||||||
return True, {
|
return True, {
|
||||||
'text': question_text,
|
'text': question_text,
|
||||||
'options': options_data,
|
'options': options_data,
|
||||||
'correct_answer': correct_answer,
|
'correct_answer': correct_answer,
|
||||||
'has_answer': has_answer
|
'has_answer': has_answer,
|
||||||
|
'question_type': question_type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def import_question_file(file_path: Path, base_path: Path, stats: ImportStats):
|
def import_question_file(file_path: Path, base_path: Path, stats: ImportStats, force: bool = False):
|
||||||
"""Import a single question file"""
|
"""
|
||||||
|
Import a single question file, checking modification time to avoid unnecessary updates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the question file
|
||||||
|
base_path: Base path for relative calculations
|
||||||
|
stats: ImportStats object to track statistics
|
||||||
|
force: If True, import regardless of mtime (for initial import)
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Get file modification time
|
||||||
|
file_mtime = file_path.stat().st_mtime
|
||||||
|
|
||||||
|
# Calculate path relative to project root
|
||||||
|
from django.conf import settings
|
||||||
|
project_root = settings.BASE_DIR.parent
|
||||||
|
try:
|
||||||
|
file_path_str = str(file_path.relative_to(project_root))
|
||||||
|
except ValueError:
|
||||||
|
file_path_str = str(file_path.relative_to(base_path))
|
||||||
|
|
||||||
|
# Check if file has changed by comparing mtime
|
||||||
|
if not force:
|
||||||
|
try:
|
||||||
|
existing_question = Question.objects.get(file_path=file_path_str)
|
||||||
|
if existing_question.file_mtime and existing_question.file_mtime >= file_mtime:
|
||||||
|
# File hasn't changed, skip
|
||||||
|
return 'skipped_unchanged'
|
||||||
|
except Question.DoesNotExist:
|
||||||
|
pass # New file, will import
|
||||||
|
|
||||||
content = file_path.read_text(encoding='utf-8')
|
content = file_path.read_text(encoding='utf-8')
|
||||||
is_mcq, question_data = parse_markdown_question(file_path, content)
|
is_mcq, question_data = parse_markdown_question(file_path, content)
|
||||||
|
|
||||||
@@ -171,31 +277,30 @@ def import_question_file(file_path: Path, base_path: Path, stats: ImportStats):
|
|||||||
|
|
||||||
if not is_mcq:
|
if not is_mcq:
|
||||||
stats.non_mcq_skipped += 1
|
stats.non_mcq_skipped += 1
|
||||||
return
|
return 'skipped_not_mcq'
|
||||||
|
|
||||||
stats.mcq_questions += 1
|
stats.mcq_questions += 1
|
||||||
stats.by_folder[folder_name]['mcq'] += 1
|
stats.by_folder[folder_name]['mcq'] += 1
|
||||||
|
|
||||||
if not question_data or not question_data.get('text'):
|
if not question_data or not question_data.get('text'):
|
||||||
stats.non_mcq_skipped += 1
|
stats.non_mcq_skipped += 1
|
||||||
return
|
return 'skipped_invalid'
|
||||||
|
|
||||||
if not question_data['has_answer']:
|
if not question_data['has_answer']:
|
||||||
stats.questions_with_todo += 1
|
stats.questions_with_todo += 1
|
||||||
stats.by_folder[folder_name]['todo'] += 1
|
stats.by_folder[folder_name]['todo'] += 1
|
||||||
return # Skip questions without answers
|
return 'skipped_todo'
|
||||||
|
|
||||||
stats.questions_with_answers += 1
|
stats.questions_with_answers += 1
|
||||||
stats.by_folder[folder_name]['answered'] += 1
|
stats.by_folder[folder_name]['answered'] += 1
|
||||||
|
|
||||||
# Import to database
|
# Import to database with mtime tracking
|
||||||
file_path_str = str(file_path.relative_to(base_path.parent))
|
|
||||||
|
|
||||||
question, created = Question.objects.update_or_create(
|
question, created = Question.objects.update_or_create(
|
||||||
file_path=file_path_str,
|
file_path=file_path_str,
|
||||||
defaults={
|
defaults={
|
||||||
'text': question_data['text'],
|
'text': question_data['text'],
|
||||||
'correct_answer': question_data['correct_answer'],
|
'correct_answer': question_data['correct_answer'],
|
||||||
|
'file_mtime': file_mtime, # Track modification time
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -206,21 +311,29 @@ def import_question_file(file_path: Path, base_path: Path, stats: ImportStats):
|
|||||||
|
|
||||||
# Update options
|
# Update options
|
||||||
question.options.all().delete()
|
question.options.all().delete()
|
||||||
|
# Deduplicate options by letter (keep first occurrence)
|
||||||
|
seen_letters = set()
|
||||||
for letter, text in question_data['options']:
|
for letter, text in question_data['options']:
|
||||||
Option.objects.create(question=question, letter=letter, text=text)
|
if letter not in seen_letters:
|
||||||
|
Option.objects.create(question=question, letter=letter, text=text)
|
||||||
|
seen_letters.add(letter)
|
||||||
|
|
||||||
|
return 'imported' if created else 'updated'
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stats.errors += 1
|
stats.errors += 1
|
||||||
print(f"Error importing {file_path}: {e}")
|
print(f"Error importing {file_path}: {e}")
|
||||||
|
return 'error'
|
||||||
|
|
||||||
|
|
||||||
def import_questions(folder_path: Path, base_path: Path = None) -> ImportStats:
|
def import_questions(folder_path: Path, base_path: Path = None, force: bool = False) -> ImportStats:
|
||||||
"""
|
"""
|
||||||
Import all questions from a folder.
|
Import all questions from a folder.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
folder_path: Path to the folder containing question markdown files
|
folder_path: Path to the folder containing question markdown files
|
||||||
base_path: Base path for relative path calculations (defaults to folder_path)
|
base_path: Base path for relative path calculations (defaults to folder_path)
|
||||||
|
force: If True, import all files regardless of mtime (for initial import)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ImportStats object with import statistics
|
ImportStats object with import statistics
|
||||||
@@ -232,17 +345,22 @@ def import_questions(folder_path: Path, base_path: Path = None) -> ImportStats:
|
|||||||
|
|
||||||
for md_file in folder_path.rglob('*.md'):
|
for md_file in folder_path.rglob('*.md'):
|
||||||
stats.total_files += 1
|
stats.total_files += 1
|
||||||
import_question_file(md_file, base_path, stats)
|
import_question_file(md_file, base_path, stats, force=force)
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
|
|
||||||
def delete_question_by_path(file_path: Path, base_path: Path):
|
def delete_question_by_path(file_path: Path):
|
||||||
"""Delete a question from the database by file path"""
|
"""Delete a question from the database by file path"""
|
||||||
try:
|
try:
|
||||||
file_path_str = str(file_path.relative_to(base_path.parent))
|
from django.conf import settings
|
||||||
Question.objects.filter(file_path=file_path_str).delete()
|
project_root = settings.BASE_DIR.parent
|
||||||
print(f"Deleted question: {file_path_str}")
|
file_path_str = str(file_path.relative_to(project_root))
|
||||||
|
deleted_count, _ = Question.objects.filter(file_path=file_path_str).delete()
|
||||||
|
if deleted_count > 0:
|
||||||
|
print(f"[Auto-delete] ✓ Deleted question: {file_path.name}")
|
||||||
|
return deleted_count > 0
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error deleting question {file_path}: {e}")
|
print(f"[Auto-delete] ✗ Error deleting question {file_path}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from quiz.utils.importer import import_question_file, delete_question_by_path, I
|
|||||||
|
|
||||||
|
|
||||||
class QuestionFileHandler(FileSystemEventHandler):
|
class QuestionFileHandler(FileSystemEventHandler):
|
||||||
"""Handle file system events for question markdown files"""
|
"""Handle file system events for question markdown files with mtime checking"""
|
||||||
|
|
||||||
def __init__(self, base_path: Path, watch_path: Path):
|
def __init__(self, base_path: Path, watch_path: Path):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -18,29 +18,38 @@ class QuestionFileHandler(FileSystemEventHandler):
|
|||||||
self.debounce_seconds = 2
|
self.debounce_seconds = 2
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
def _debounced_import(self, file_path: Path):
|
def _debounced_import(self, file_path: Path, event_type: str):
|
||||||
"""Import file after debounce delay"""
|
"""Import file after debounce delay, checking mtime for actual changes"""
|
||||||
time.sleep(self.debounce_seconds)
|
time.sleep(self.debounce_seconds)
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if file_path in self.pending_events:
|
if file_path in self.pending_events:
|
||||||
del self.pending_events[file_path]
|
del self.pending_events[file_path]
|
||||||
|
|
||||||
if file_path.exists():
|
if not file_path.exists():
|
||||||
print(f"\n[Auto-import] Processing: {file_path.name}")
|
return
|
||||||
stats = ImportStats()
|
|
||||||
import_question_file(file_path, self.watch_path, stats)
|
|
||||||
|
|
||||||
if stats.created > 0:
|
# Import with mtime checking (force=False means only import if changed)
|
||||||
print(f"[Auto-import] ✓ Created question from {file_path.name}")
|
stats = ImportStats()
|
||||||
elif stats.updated > 0:
|
result = import_question_file(file_path, self.watch_path, stats, force=False)
|
||||||
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):
|
# Provide feedback based on result
|
||||||
|
if result == 'imported':
|
||||||
|
print(f"\n[Auto-import] ✓ Created: {file_path.name}")
|
||||||
|
elif result == 'updated':
|
||||||
|
print(f"\n[Auto-import] ✓ Updated: {file_path.name}")
|
||||||
|
elif result == 'skipped_unchanged':
|
||||||
|
# File hasn't actually changed (same mtime), no output
|
||||||
|
pass
|
||||||
|
elif result == 'skipped_todo':
|
||||||
|
print(f"\n[Auto-import] ⊘ Skipped: {file_path.name} (TODO answer)")
|
||||||
|
elif result == 'skipped_not_mcq':
|
||||||
|
# Silently skip non-MCQ files
|
||||||
|
pass
|
||||||
|
elif result == 'error':
|
||||||
|
print(f"\n[Auto-import] ✗ Error: {file_path.name}")
|
||||||
|
|
||||||
|
def _handle_file_change(self, file_path: Path, event_type: str = 'modified'):
|
||||||
"""Handle file creation or modification with debouncing"""
|
"""Handle file creation or modification with debouncing"""
|
||||||
if not file_path.suffix == '.md':
|
if not file_path.suffix == '.md':
|
||||||
return
|
return
|
||||||
@@ -51,26 +60,29 @@ class QuestionFileHandler(FileSystemEventHandler):
|
|||||||
self.pending_events[file_path].cancel()
|
self.pending_events[file_path].cancel()
|
||||||
|
|
||||||
# Schedule new import
|
# Schedule new import
|
||||||
timer = threading.Timer(self.debounce_seconds, self._debounced_import, args=[file_path])
|
timer = threading.Timer(
|
||||||
|
self.debounce_seconds,
|
||||||
|
self._debounced_import,
|
||||||
|
args=[file_path, event_type]
|
||||||
|
)
|
||||||
self.pending_events[file_path] = timer
|
self.pending_events[file_path] = timer
|
||||||
timer.start()
|
timer.start()
|
||||||
|
|
||||||
def on_created(self, event: FileSystemEvent):
|
def on_created(self, event: FileSystemEvent):
|
||||||
"""Handle file creation"""
|
"""Handle file creation"""
|
||||||
if not event.is_directory:
|
if not event.is_directory:
|
||||||
self._handle_file_change(Path(event.src_path))
|
self._handle_file_change(Path(event.src_path), 'created')
|
||||||
|
|
||||||
def on_modified(self, event: FileSystemEvent):
|
def on_modified(self, event: FileSystemEvent):
|
||||||
"""Handle file modification"""
|
"""Handle file modification"""
|
||||||
if not event.is_directory:
|
if not event.is_directory:
|
||||||
self._handle_file_change(Path(event.src_path))
|
self._handle_file_change(Path(event.src_path), 'modified')
|
||||||
|
|
||||||
def on_deleted(self, event: FileSystemEvent):
|
def on_deleted(self, event: FileSystemEvent):
|
||||||
"""Handle file deletion"""
|
"""Handle file deletion"""
|
||||||
if not event.is_directory and event.src_path.endswith('.md'):
|
if not event.is_directory and event.src_path.endswith('.md'):
|
||||||
file_path = Path(event.src_path)
|
file_path = Path(event.src_path)
|
||||||
print(f"\n[Auto-import] Deleting: {file_path.name}")
|
delete_question_by_path(file_path)
|
||||||
delete_question_by_path(file_path, self.watch_path)
|
|
||||||
|
|
||||||
|
|
||||||
class QuestionWatcher:
|
class QuestionWatcher:
|
||||||
@@ -116,10 +128,16 @@ def start_watcher_thread():
|
|||||||
print(f"[QuestionWatcher] Warning: Watch path does not exist: {watch_path}")
|
print(f"[QuestionWatcher] Warning: Watch path does not exist: {watch_path}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Initial import
|
# Initial import with mtime checking (force=False to only import changed files)
|
||||||
print("\n[QuestionWatcher] Starting initial import...")
|
print("\n[QuestionWatcher] Checking for changes...")
|
||||||
stats = import_questions(watch_path, watch_path)
|
stats = import_questions(watch_path, watch_path, force=False)
|
||||||
print(stats.format_output())
|
|
||||||
|
# Only show stats if there were changes
|
||||||
|
output = stats.format_output(show_if_no_changes=False)
|
||||||
|
if output:
|
||||||
|
print(output)
|
||||||
|
else:
|
||||||
|
print(f"[QuestionWatcher] ✓ All files up to date")
|
||||||
|
|
||||||
# Start watching for changes
|
# Start watching for changes
|
||||||
watcher = QuestionWatcher(watch_path, watch_path)
|
watcher = QuestionWatcher(watch_path, watch_path)
|
||||||
|
|||||||
@@ -17,8 +17,11 @@ ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
|||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
'django.contrib.contenttypes',
|
'django.contrib.contenttypes',
|
||||||
'django.contrib.sessions',
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'quiz.apps.QuizAppConfig',
|
'quiz.apps.QuizAppConfig',
|
||||||
]
|
]
|
||||||
@@ -28,6 +31,8 @@ MIDDLEWARE = [
|
|||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
'quiz.middleware.LazyAuthMiddleware',
|
'quiz.middleware.LazyAuthMiddleware',
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -41,6 +46,7 @@ TEMPLATES = [{
|
|||||||
'context_processors': [
|
'context_processors': [
|
||||||
'django.template.context_processors.debug',
|
'django.template.context_processors.debug',
|
||||||
'django.template.context_processors.request',
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
'django.contrib.messages.context_processors.messages',
|
'django.contrib.messages.context_processors.messages',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user