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:
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 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()
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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'))
|
||||
|
||||
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user