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