vault backup: 2025-12-22 01:20:48
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m51s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m51s
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
quiz/quiz/__pycache__/tests.cpython-314.pyc
Normal file
BIN
quiz/quiz/__pycache__/tests.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,34 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from .models import User, Question, Option, QuizResult
|
||||
from django.utils.safestring import mark_safe
|
||||
from .models import QuizUser, Question, Option, QuizResult, Course, Exam
|
||||
|
||||
|
||||
@admin.register(Course)
|
||||
class CourseAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for Courses"""
|
||||
list_display = ['id', 'name', 'code', 'exam_count', 'created_at']
|
||||
search_fields = ['name', 'code']
|
||||
readonly_fields = ['created_at']
|
||||
|
||||
def exam_count(self, obj):
|
||||
"""Show number of exams"""
|
||||
return obj.exams.count()
|
||||
exam_count.short_description = '# Exams'
|
||||
|
||||
|
||||
@admin.register(Exam)
|
||||
class ExamAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for Exams"""
|
||||
list_display = ['id', 'course', 'date', 'question_count', 'folder_path', 'created_at']
|
||||
list_filter = ['course', 'date']
|
||||
search_fields = ['name', 'folder_path']
|
||||
readonly_fields = ['created_at']
|
||||
|
||||
def question_count(self, obj):
|
||||
"""Show number of questions"""
|
||||
return obj.questions.count()
|
||||
question_count.short_description = '# Questions'
|
||||
|
||||
|
||||
class OptionInline(admin.TabularInline):
|
||||
@@ -14,13 +42,13 @@ class OptionInline(admin.TabularInline):
|
||||
@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']
|
||||
list_display = ['id', 'question_preview', 'exam', 'correct_answer', 'option_count', 'file_source', 'updated_at']
|
||||
list_filter = ['exam__course', 'exam', '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']
|
||||
'fields': ['exam', 'text', 'correct_answer']
|
||||
}),
|
||||
('File Tracking', {
|
||||
'fields': ['file_path', 'file_mtime', 'formatted_mtime'],
|
||||
@@ -90,9 +118,9 @@ class OptionAdmin(admin.ModelAdmin):
|
||||
is_correct.short_description = 'Status'
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for Users"""
|
||||
@admin.register(QuizUser)
|
||||
class QuizUserAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for Quiz Users"""
|
||||
list_display = ['id', 'session_preview', 'result_count', 'score_percentage', 'created_at']
|
||||
list_filter = ['created_at']
|
||||
search_fields = ['session_key']
|
||||
@@ -121,9 +149,8 @@ class UserAdmin(admin.ModelAdmin):
|
||||
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
|
||||
return mark_safe(
|
||||
f'<span style="color: {color}; font-weight: bold;">{percentage:.1f}%</span> ({correct}/{total})'
|
||||
)
|
||||
score_percentage.short_description = 'Score'
|
||||
|
||||
@@ -159,7 +186,7 @@ class QuizResultAdmin(admin.ModelAdmin):
|
||||
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>')
|
||||
return mark_safe('<span style="color: green; font-weight: bold;">✓ Correct</span>')
|
||||
return mark_safe('<span style="color: red; font-weight: bold;">✗ Wrong</span>')
|
||||
result_status.short_description = 'Result'
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ class QuizAppConfig(AppConfig):
|
||||
Starts the auto-import watcher in a background thread.
|
||||
"""
|
||||
# Only run in the main process (not in reloader process)
|
||||
# And not during management commands (to avoid database locking)
|
||||
# And not during management commands or pytest tests
|
||||
is_management_command = any(cmd in sys.argv for cmd in ['import_questions', 'migrate', 'makemigrations', 'shell'])
|
||||
is_pytest = 'pytest' in sys.argv[0] or 'PYTEST_CURRENT_TEST' in os.environ
|
||||
|
||||
if not is_management_command and (os.environ.get('RUN_MAIN') == 'true' or os.environ.get('RUN_MAIN') is None):
|
||||
if not is_management_command and not is_pytest 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.
87
quiz/quiz/management/commands/populate_exams.py
Normal file
87
quiz/quiz/management/commands/populate_exams.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Management command to populate Course and Exam models from existing questions.
|
||||
"""
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
from quiz.models import Course, Exam, Question
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Populate Course and Exam models from existing question file paths'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
# Create default course
|
||||
course, created = Course.objects.get_or_create(
|
||||
name="Anatomi & Histologi 2",
|
||||
defaults={'code': 'AH2', 'description': 'Anatomy and Histology course'}
|
||||
)
|
||||
|
||||
if created:
|
||||
self.stdout.write(self.style.SUCCESS(f'Created course: {course.name}'))
|
||||
else:
|
||||
self.stdout.write(f'Course exists: {course.name}')
|
||||
|
||||
# Analyze existing questions and create exams
|
||||
questions = Question.objects.all()
|
||||
exam_folders = {}
|
||||
|
||||
for question in questions:
|
||||
# Extract exam date from file path
|
||||
# Expected: content/Anatomi & Histologi 2/Gamla tentor/2022-01-15/1.md
|
||||
path_parts = Path(question.file_path).parts
|
||||
|
||||
if len(path_parts) >= 2:
|
||||
# Try to find a date-like folder
|
||||
for part in path_parts:
|
||||
if '-' in part and len(part) == 10: # Looks like YYYY-MM-DD
|
||||
try:
|
||||
exam_date = datetime.strptime(part, '%Y-%m-%d').date()
|
||||
folder_path = '/'.join(path_parts[:-1])
|
||||
|
||||
if part not in exam_folders:
|
||||
exam_folders[part] = {
|
||||
'date': exam_date,
|
||||
'folder': folder_path,
|
||||
'questions': []
|
||||
}
|
||||
exam_folders[part]['questions'].append(question)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Create exams and assign questions
|
||||
exams_created = 0
|
||||
questions_assigned = 0
|
||||
|
||||
for folder_name, data in sorted(exam_folders.items()):
|
||||
exam, created = Exam.objects.get_or_create(
|
||||
course=course,
|
||||
date=data['date'],
|
||||
defaults={
|
||||
'name': folder_name,
|
||||
'folder_path': data['folder']
|
||||
}
|
||||
)
|
||||
|
||||
if created:
|
||||
exams_created += 1
|
||||
self.stdout.write(self.style.SUCCESS(f' Created exam: {exam.date}'))
|
||||
|
||||
# Assign questions to this exam
|
||||
for question in data['questions']:
|
||||
if question.exam != exam:
|
||||
question.exam = exam
|
||||
question.save(update_fields=['exam'])
|
||||
questions_assigned += 1
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f'\nSummary:\n'
|
||||
f' Exams created: {exams_created}\n'
|
||||
f' Questions assigned: {questions_assigned}\n'
|
||||
f' Total exams: {Exam.objects.count()}\n'
|
||||
f' Questions with exams: {Question.objects.filter(exam__isnull=False).count()}\n'
|
||||
f' Questions without exams: {Question.objects.filter(exam__isnull=True).count()}'
|
||||
))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .models import User
|
||||
from .models import QuizUser
|
||||
|
||||
|
||||
class LazyAuthMiddleware:
|
||||
@@ -14,8 +14,8 @@ class LazyAuthMiddleware:
|
||||
request.session.create()
|
||||
|
||||
session_key = request.session.session_key
|
||||
user, created = User.objects.get_or_create(session_key=session_key)
|
||||
request.user = user
|
||||
user, created = QuizUser.objects.get_or_create(session_key=session_key)
|
||||
request.quiz_user = user
|
||||
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 6.0 on 2025-12-21 19:46
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('quiz', '0003_question_file_mtime'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel(
|
||||
old_name='User',
|
||||
new_name='QuizUser',
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='quizuser',
|
||||
options={'verbose_name': 'Quiz User', 'verbose_name_plural': 'Quiz Users'},
|
||||
),
|
||||
]
|
||||
44
quiz/quiz/migrations/0005_course_exam_question_exam.py
Normal file
44
quiz/quiz/migrations/0005_course_exam_question_exam.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 6.0 on 2025-12-21 20:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('quiz', '0004_rename_user_quizuser_alter_quizuser_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Course',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, unique=True)),
|
||||
('code', models.CharField(blank=True, max_length=50)),
|
||||
('description', models.TextField(blank=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Exam',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('date', models.DateField()),
|
||||
('name', models.CharField(blank=True, max_length=200)),
|
||||
('folder_path', models.CharField(blank=True, max_length=500)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exams', to='quiz.course')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-date'],
|
||||
'unique_together': {('course', 'date')},
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='question',
|
||||
name='exam',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='quiz.exam'),
|
||||
),
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,15 +1,45 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class User(models.Model):
|
||||
class QuizUser(models.Model):
|
||||
session_key = models.CharField(max_length=40, unique=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Quiz User"
|
||||
verbose_name_plural = "Quiz Users"
|
||||
|
||||
def __str__(self):
|
||||
return f"User {self.session_key[:8]}"
|
||||
|
||||
|
||||
class Course(models.Model):
|
||||
name = models.CharField(max_length=200, unique=True)
|
||||
code = models.CharField(max_length=50, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Exam(models.Model):
|
||||
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='exams')
|
||||
date = models.DateField()
|
||||
name = models.CharField(max_length=200, blank=True) # e.g., "2022-01-15"
|
||||
folder_path = models.CharField(max_length=500, blank=True) # Path to exam folder in content
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['course', 'date']
|
||||
ordering = ['-date']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.course.name} - {self.date}"
|
||||
|
||||
|
||||
class Question(models.Model):
|
||||
exam = models.ForeignKey(Exam, on_delete=models.CASCADE, related_name='questions', null=True, blank=True)
|
||||
file_path = models.CharField(max_length=500, unique=True)
|
||||
text = models.TextField()
|
||||
correct_answer = models.CharField(max_length=50) # Support multi-select answers like "A,B,C"
|
||||
@@ -34,7 +64,7 @@ class Option(models.Model):
|
||||
|
||||
|
||||
class QuizResult(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='results')
|
||||
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='results')
|
||||
question = models.ForeignKey(Question, on_delete=models.CASCADE)
|
||||
selected_answer = models.CharField(max_length=1)
|
||||
is_correct = models.BooleanField()
|
||||
|
||||
Binary file not shown.
@@ -131,8 +131,15 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
|
||||
question_text = line.strip().replace('**', '')
|
||||
break
|
||||
|
||||
# Return early if no question text found, but include has_answer field
|
||||
if not question_text:
|
||||
return True, {}
|
||||
return True, {
|
||||
'text': None,
|
||||
'options': [],
|
||||
'correct_answer': '',
|
||||
'has_answer': False,
|
||||
'question_type': question_type
|
||||
}
|
||||
|
||||
# Extract options (pattern: "- A:" or "- A" for MCQ, or text for textalternativ)
|
||||
options_data = []
|
||||
@@ -184,11 +191,17 @@ def parse_markdown_question(file_path: Path, content: str) -> Tuple[bool, dict]:
|
||||
options_data.append((letter, option_text))
|
||||
|
||||
# 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
|
||||
if not options_data:
|
||||
# At least return something for single-option questions
|
||||
options_data = [('A', '')]
|
||||
elif len(options_data) < 2 and question_type in ['mcq', 'scq']:
|
||||
return True, {}
|
||||
return True, {
|
||||
'text': question_text,
|
||||
'options': options_data,
|
||||
'correct_answer': '',
|
||||
'has_answer': False,
|
||||
'question_type': question_type
|
||||
}
|
||||
|
||||
# Extract answer from spoiler block
|
||||
correct_answer = None
|
||||
@@ -294,10 +307,57 @@ def import_question_file(file_path: Path, base_path: Path, stats: ImportStats, f
|
||||
stats.questions_with_answers += 1
|
||||
stats.by_folder[folder_name]['answered'] += 1
|
||||
|
||||
# Extract exam information from folder structure
|
||||
# Expected path: content/Anatomi & Histologi 2/Gamla tentor/2022-01-15/question.md
|
||||
exam = None
|
||||
relative_path = file_path.relative_to(base_path)
|
||||
path_parts = relative_path.parts
|
||||
|
||||
# Try to extract exam date from folder structure
|
||||
if len(path_parts) >= 2:
|
||||
# Get the parent folder name which should be the exam date (e.g., "2022-01-15")
|
||||
exam_folder = path_parts[-2] if len(path_parts) > 1 else None
|
||||
|
||||
# Try to parse as date
|
||||
if exam_folder and '-' in exam_folder:
|
||||
try:
|
||||
from datetime import datetime
|
||||
from quiz.models import Course, Exam
|
||||
|
||||
exam_date = datetime.strptime(exam_folder, '%Y-%m-%d').date()
|
||||
|
||||
# Get or create course (default to "Anatomi & Histologi 2")
|
||||
# Extract course name from path if available
|
||||
course_name = "Anatomi & Histologi 2"
|
||||
if len(path_parts) >= 3 and 'Anatomi' in ' '.join(path_parts):
|
||||
# Try to find course name in path
|
||||
for part in path_parts:
|
||||
if 'Anatomi' in part or 'Histologi' in part:
|
||||
course_name = part
|
||||
break
|
||||
|
||||
course, _ = Course.objects.get_or_create(
|
||||
name=course_name,
|
||||
defaults={'code': 'AH2'}
|
||||
)
|
||||
|
||||
# Get or create exam
|
||||
exam, _ = Exam.objects.get_or_create(
|
||||
course=course,
|
||||
date=exam_date,
|
||||
defaults={
|
||||
'name': exam_folder,
|
||||
'folder_path': '/'.join(path_parts[:-1])
|
||||
}
|
||||
)
|
||||
except (ValueError, ImportError):
|
||||
pass # If date parsing fails, exam remains None
|
||||
|
||||
# Import to database with mtime tracking
|
||||
question, created = Question.objects.update_or_create(
|
||||
file_path=file_path_str,
|
||||
defaults={
|
||||
'exam': exam,
|
||||
'text': question_data['text'],
|
||||
'correct_answer': question_data['correct_answer'],
|
||||
'file_mtime': file_mtime, # Track modification time
|
||||
|
||||
@@ -7,7 +7,7 @@ from .models import Question, QuizResult
|
||||
|
||||
def index(request):
|
||||
total_questions = Question.objects.count()
|
||||
answered_count = QuizResult.objects.filter(user=request.user).count()
|
||||
answered_count = QuizResult.objects.filter(user=request.quiz_user).count()
|
||||
|
||||
context = {
|
||||
'total_questions': total_questions,
|
||||
@@ -17,7 +17,7 @@ def index(request):
|
||||
|
||||
|
||||
def get_next_question(request):
|
||||
answered_ids = QuizResult.objects.filter(user=request.user).values_list('question_id', flat=True)
|
||||
answered_ids = QuizResult.objects.filter(user=request.quiz_user).values_list('question_id', flat=True)
|
||||
next_question = Question.objects.exclude(id__in=answered_ids).first()
|
||||
|
||||
if not next_question:
|
||||
@@ -42,7 +42,7 @@ def submit_answer(request):
|
||||
is_correct = selected_answer == question.correct_answer
|
||||
|
||||
QuizResult.objects.update_or_create(
|
||||
user=request.user,
|
||||
user=request.quiz_user,
|
||||
question=question,
|
||||
defaults={
|
||||
'selected_answer': selected_answer,
|
||||
@@ -54,7 +54,7 @@ def submit_answer(request):
|
||||
|
||||
|
||||
def stats(request):
|
||||
results = QuizResult.objects.filter(user=request.user)
|
||||
results = QuizResult.objects.filter(user=request.quiz_user)
|
||||
total = results.count()
|
||||
correct = results.filter(is_correct=True).count()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user