1
0

vault backup: 2025-12-22 01:20:48
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m51s

This commit is contained in:
2025-12-22 01:20:48 +01:00
parent 9b90279c8a
commit 098dc9c8db
38 changed files with 1225 additions and 63 deletions

Binary file not shown.

View File

@@ -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'

View File

@@ -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()

View 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()}'
))

View File

@@ -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)

View File

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

View 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'),
),
]

View File

@@ -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()

View File

@@ -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

View File

@@ -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()