diff --git a/quiz/ADMIN_README.md b/quiz/ADMIN_README.md
new file mode 100644
index 0000000..e69de29
diff --git a/quiz/IMPLEMENTATION_SUMMARY.md b/quiz/IMPLEMENTATION_SUMMARY.md
deleted file mode 100644
index 3a20cf1..0000000
--- a/quiz/IMPLEMENTATION_SUMMARY.md
+++ /dev/null
@@ -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()
- ```
-
diff --git a/quiz/__pycache__/settings.cpython-314.pyc b/quiz/__pycache__/settings.cpython-314.pyc
index edfaa22..67cd892 100644
Binary files a/quiz/__pycache__/settings.cpython-314.pyc and b/quiz/__pycache__/settings.cpython-314.pyc differ
diff --git a/quiz/db.sqlite3 b/quiz/db.sqlite3
index f79847d..b62fac9 100644
Binary files a/quiz/db.sqlite3 and b/quiz/db.sqlite3 differ
diff --git a/quiz/db.sqlite3-shm b/quiz/db.sqlite3-shm
index 71677fc..9a677cb 100644
Binary files a/quiz/db.sqlite3-shm and b/quiz/db.sqlite3-shm differ
diff --git a/quiz/db.sqlite3-wal b/quiz/db.sqlite3-wal
index f9e7391..716a0db 100644
Binary files a/quiz/db.sqlite3-wal and b/quiz/db.sqlite3-wal differ
diff --git a/quiz/quiz/__pycache__/admin.cpython-314.pyc b/quiz/quiz/__pycache__/admin.cpython-314.pyc
new file mode 100644
index 0000000..69e57a1
Binary files /dev/null and b/quiz/quiz/__pycache__/admin.cpython-314.pyc differ
diff --git a/quiz/quiz/__pycache__/apps.cpython-314.pyc b/quiz/quiz/__pycache__/apps.cpython-314.pyc
index 8035547..69d8737 100644
Binary files a/quiz/quiz/__pycache__/apps.cpython-314.pyc and b/quiz/quiz/__pycache__/apps.cpython-314.pyc differ
diff --git a/quiz/quiz/__pycache__/models.cpython-314.pyc b/quiz/quiz/__pycache__/models.cpython-314.pyc
index 372c6a7..94fe7a0 100644
Binary files a/quiz/quiz/__pycache__/models.cpython-314.pyc and b/quiz/quiz/__pycache__/models.cpython-314.pyc differ
diff --git a/quiz/quiz/__pycache__/urls.cpython-314.pyc b/quiz/quiz/__pycache__/urls.cpython-314.pyc
index 6172155..d05d334 100644
Binary files a/quiz/quiz/__pycache__/urls.cpython-314.pyc and b/quiz/quiz/__pycache__/urls.cpython-314.pyc differ
diff --git a/quiz/quiz/admin.py b/quiz/quiz/admin.py
new file mode 100644
index 0000000..9f032a3
--- /dev/null
+++ b/quiz/quiz/admin.py
@@ -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('{}/{}', 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('✓ Correct')
+ return format_html('-')
+ 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(
+ '{:.1f}% ({}/{})',
+ 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('✓ Correct')
+ return format_html('✗ Wrong')
+ result_status.short_description = 'Result'
+
diff --git a/quiz/quiz/apps.py b/quiz/quiz/apps.py
index 76efcaa..2a08bc2 100644
--- a/quiz/quiz/apps.py
+++ b/quiz/quiz/apps.py
@@ -1,4 +1,5 @@
import os
+import sys
from django.apps import AppConfig
@@ -12,7 +13,10 @@ class QuizAppConfig(AppConfig):
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:
+ # 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
start_watcher_thread()
diff --git a/quiz/quiz/management/commands/__pycache__/import_questions.cpython-314.pyc b/quiz/quiz/management/commands/__pycache__/import_questions.cpython-314.pyc
index 6d8f69d..13e9bed 100644
Binary files a/quiz/quiz/management/commands/__pycache__/import_questions.cpython-314.pyc and b/quiz/quiz/management/commands/__pycache__/import_questions.cpython-314.pyc differ
diff --git a/quiz/quiz/management/commands/import_questions.py b/quiz/quiz/management/commands/import_questions.py
index 8b5dffe..98cf133 100644
--- a/quiz/quiz/management/commands/import_questions.py
+++ b/quiz/quiz/management/commands/import_questions.py
@@ -26,12 +26,16 @@ class Command(BaseCommand):
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'))
+ # Only show full statistics if there were changes
+ output = stats.format_output(show_if_no_changes=False)
+ if output:
+ self.stdout.write(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!'))
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'))
diff --git a/quiz/quiz/migrations/0002_alter_question_correct_answer.py b/quiz/quiz/migrations/0002_alter_question_correct_answer.py
new file mode 100644
index 0000000..2b0dc81
--- /dev/null
+++ b/quiz/quiz/migrations/0002_alter_question_correct_answer.py
@@ -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),
+ ),
+ ]
diff --git a/quiz/quiz/migrations/0003_question_file_mtime.py b/quiz/quiz/migrations/0003_question_file_mtime.py
new file mode 100644
index 0000000..c760c69
--- /dev/null
+++ b/quiz/quiz/migrations/0003_question_file_mtime.py
@@ -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),
+ ),
+ ]
diff --git a/quiz/quiz/migrations/__pycache__/0002_alter_question_correct_answer.cpython-314.pyc b/quiz/quiz/migrations/__pycache__/0002_alter_question_correct_answer.cpython-314.pyc
new file mode 100644
index 0000000..1ea917b
Binary files /dev/null and b/quiz/quiz/migrations/__pycache__/0002_alter_question_correct_answer.cpython-314.pyc differ
diff --git a/quiz/quiz/migrations/__pycache__/0003_question_file_mtime.cpython-314.pyc b/quiz/quiz/migrations/__pycache__/0003_question_file_mtime.cpython-314.pyc
new file mode 100644
index 0000000..aa5a21e
Binary files /dev/null and b/quiz/quiz/migrations/__pycache__/0003_question_file_mtime.cpython-314.pyc differ
diff --git a/quiz/quiz/models.py b/quiz/quiz/models.py
index dafd814..6cdf087 100644
--- a/quiz/quiz/models.py
+++ b/quiz/quiz/models.py
@@ -12,7 +12,8 @@ class User(models.Model):
class Question(models.Model):
file_path = models.CharField(max_length=500, unique=True)
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)
updated_at = models.DateTimeField(auto_now=True)
diff --git a/quiz/quiz/urls.py b/quiz/quiz/urls.py
index 3b2d602..40c4526 100644
--- a/quiz/quiz/urls.py
+++ b/quiz/quiz/urls.py
@@ -1,8 +1,10 @@
+from django.contrib import admin
from django.urls import path
from .views import index, get_next_question, submit_answer, stats
urlpatterns = [
+ path('admin/', admin.site.urls),
path('', index, name='index'),
path('next/', get_next_question, name='next_question'),
path('submit/', submit_answer, name='submit_answer'),
diff --git a/quiz/quiz/utils/__pycache__/importer.cpython-314.pyc b/quiz/quiz/utils/__pycache__/importer.cpython-314.pyc
index f8c80fd..f18029b 100644
Binary files a/quiz/quiz/utils/__pycache__/importer.cpython-314.pyc and b/quiz/quiz/utils/__pycache__/importer.cpython-314.pyc differ
diff --git a/quiz/quiz/utils/__pycache__/watcher.cpython-314.pyc b/quiz/quiz/utils/__pycache__/watcher.cpython-314.pyc
index 5487922..d276ff9 100644
Binary files a/quiz/quiz/utils/__pycache__/watcher.cpython-314.pyc and b/quiz/quiz/utils/__pycache__/watcher.cpython-314.pyc differ
diff --git a/quiz/quiz/utils/importer.py b/quiz/quiz/utils/importer.py
index 8edcffa..73f472c 100644
--- a/quiz/quiz/utils/importer.py
+++ b/quiz/quiz/utils/importer.py
@@ -24,8 +24,20 @@ class ImportStats:
'todo': 0
})
- def format_output(self) -> str:
- """Format statistics for console output"""
+ def has_changes(self) -> bool:
+ """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.append("\n" + "="*70)
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')
- # Check for MCQ tags in frontmatter
- is_mcq = False
+ # Check for question tags in frontmatter
+ # Accept: frågetyp/mcq, frågetyp/scq, frågetyp/textalternativ, frågetyp/textfält
+ is_question = False
+ question_type = None
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
+ if in_frontmatter and 'frågetyp/' in line:
+ 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
- if not is_mcq:
+ if not is_question:
return False, {}
# 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:
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 = []
+ in_frontmatter = False
+ frontmatter_done = False
+ in_spoiler = False
+
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 = re.match(r'^-\s*([A-Z]):\s*(.*)$', line.strip())
if not match:
@@ -123,14 +172,29 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
letter = match.group(1)
text = match.group(2) if len(match.groups()) > 1 else ""
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, {}
# Extract answer from spoiler block
correct_answer = None
has_answer = False
in_spoiler = False
+ answer_lines = []
for line in lines:
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() == '```':
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
+ if stripped:
+ answer_lines.append(stripped)
+
+ # Process collected answer lines
+ if answer_lines:
+ full_answer = ' '.join(answer_lines)
+
+ # Check for TODO
+ 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, {
'text': question_text,
'options': options_data,
'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):
- """Import a single question file"""
+def import_question_file(file_path: Path, base_path: Path, stats: ImportStats, force: bool = False):
+ """
+ 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:
+ # 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')
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:
stats.non_mcq_skipped += 1
- return
+ return 'skipped_not_mcq'
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
+ return 'skipped_invalid'
if not question_data['has_answer']:
stats.questions_with_todo += 1
stats.by_folder[folder_name]['todo'] += 1
- return # Skip questions without answers
+ return 'skipped_todo'
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))
-
+ # Import to database with mtime tracking
question, created = Question.objects.update_or_create(
file_path=file_path_str,
defaults={
'text': question_data['text'],
'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
question.options.all().delete()
+ # Deduplicate options by letter (keep first occurrence)
+ seen_letters = set()
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:
stats.errors += 1
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.
Args:
folder_path: Path to the folder containing question markdown files
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:
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'):
stats.total_files += 1
- import_question_file(md_file, base_path, stats)
+ import_question_file(md_file, base_path, stats, force=force)
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"""
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}")
+ from django.conf import settings
+ project_root = settings.BASE_DIR.parent
+ 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:
- print(f"Error deleting question {file_path}: {e}")
+ print(f"[Auto-delete] ✗ Error deleting question {file_path}: {e}")
+ return False
diff --git a/quiz/quiz/utils/watcher.py b/quiz/quiz/utils/watcher.py
index 90aff6e..8ac5806 100644
--- a/quiz/quiz/utils/watcher.py
+++ b/quiz/quiz/utils/watcher.py
@@ -8,7 +8,7 @@ from quiz.utils.importer import import_question_file, delete_question_by_path, I
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):
super().__init__()
@@ -18,29 +18,38 @@ class QuestionFileHandler(FileSystemEventHandler):
self.debounce_seconds = 2
self.lock = threading.Lock()
- def _debounced_import(self, file_path: Path):
- """Import file after debounce delay"""
+ def _debounced_import(self, file_path: Path, event_type: str):
+ """Import file after debounce delay, checking mtime for actual changes"""
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 not file_path.exists():
+ return
- 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)")
+ # Import with mtime checking (force=False means only import if changed)
+ stats = ImportStats()
+ result = import_question_file(file_path, self.watch_path, stats, force=False)
- 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"""
if not file_path.suffix == '.md':
return
@@ -51,26 +60,29 @@ class QuestionFileHandler(FileSystemEventHandler):
self.pending_events[file_path].cancel()
# 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
timer.start()
def on_created(self, event: FileSystemEvent):
"""Handle file creation"""
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):
"""Handle file modification"""
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):
"""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)
+ delete_question_by_path(file_path)
class QuestionWatcher:
@@ -116,10 +128,16 @@ def start_watcher_thread():
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())
+ # Initial import with mtime checking (force=False to only import changed files)
+ print("\n[QuestionWatcher] Checking for changes...")
+ stats = import_questions(watch_path, watch_path, force=False)
+
+ # 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
watcher = QuestionWatcher(watch_path, watch_path)
diff --git a/quiz/settings.py b/quiz/settings.py
index 6d2fbb4..14b9941 100644
--- a/quiz/settings.py
+++ b/quiz/settings.py
@@ -17,8 +17,11 @@ ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
# Application definition
INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
+ 'django.contrib.messages',
'django.contrib.staticfiles',
'quiz.apps.QuizAppConfig',
]
@@ -28,6 +31,8 @@ MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
+ 'django.contrib.auth.middleware.AuthenticationMiddleware',
+ 'django.contrib.messages.middleware.MessageMiddleware',
'quiz.middleware.LazyAuthMiddleware',
]
@@ -41,6 +46,7 @@ TEMPLATES = [{
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
+ 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},