1
0

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

This commit is contained in:
2025-12-22 12:22:20 +01:00
parent d1d89e5442
commit 5169a67966
23 changed files with 12058 additions and 288 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -1,26 +1,35 @@
from django import forms
from django.db.models import Count
from .models import Course, Tag
from .models import Course, Tag, Exam
class TagModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj):
return f"{obj.name} ({obj.question_count})"
class ExamModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj):
return f"{obj.course.name} - {obj.date}"
class CreateQuizForm(forms.Form):
course = forms.ModelChoiceField(
queryset=Course.objects.all(),
required=False,
empty_label="All Courses",
empty_label="Alla kurser",
widget=forms.Select(attrs={'class': 'form-control'})
)
exams = ExamModelMultipleChoiceField(
queryset=Exam.objects.all(),
required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-control'})
)
tags = TagModelMultipleChoiceField(
queryset=Tag.objects.annotate(question_count=Count('questions')).order_by('name'),
required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-control'})
)
QUESTION_TYPES = [
('single', 'Single Choice'),
('multi', 'Multiple Choice'),
('single', 'Envalsfrågor'),
('multi', 'Flervalsfrågor'),
]
question_type = forms.MultipleChoiceField(
choices=QUESTION_TYPES,

View File

@@ -0,0 +1,34 @@
# Generated by Django 6.0 on 2025-12-22 11:06
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('quiz', '0006_tag_question_tags'),
]
operations = [
migrations.CreateModel(
name='QuizSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('is_active', models.BooleanField(default=True)),
('course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='quiz.course')),
('exams', models.ManyToManyField(blank=True, to='quiz.exam')),
('tags', models.ManyToManyField(blank=True, to='quiz.tag')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quiz_sessions', to='quiz.quizuser')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='quizresult',
name='quiz_session',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='results', to='quiz.quizsession'),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-22 11:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('quiz', '0007_quizsession_quizresult_quiz_session'),
]
operations = [
migrations.AddField(
model_name='quizsession',
name='question_types',
field=models.JSONField(blank=True, default=list),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2025-12-22 11:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('quiz', '0008_quizsession_question_types'),
]
operations = [
migrations.AddField(
model_name='quizresult',
name='difficulty',
field=models.CharField(blank=True, choices=[('again', 'Again'), ('hard', 'Hard'), ('good', 'Good'), ('easy', 'Easy')], max_length=10, null=True),
),
]

View File

@@ -72,11 +72,34 @@ class Option(models.Model):
return f"{self.letter}. {self.text[:30]}"
class QuizSession(models.Model):
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='quiz_sessions')
course = models.ForeignKey(Course, on_delete=models.SET_NULL, null=True, blank=True)
exams = models.ManyToManyField(Exam, blank=True)
tags = models.ManyToManyField(Tag, blank=True)
question_types = models.JSONField(default=list, blank=True) # Store as list of strings
created_at = models.DateTimeField(auto_now_add=True)
is_active = models.BooleanField(default=True)
class Meta:
ordering = ['-created_at']
def __str__(self):
return f"Session {self.id} for {self.user}"
class QuizResult(models.Model):
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='results')
quiz_session = models.ForeignKey(QuizSession, on_delete=models.CASCADE, related_name='results', null=True, blank=True)
question = models.ForeignKey(Question, on_delete=models.CASCADE)
selected_answer = models.CharField(max_length=1)
is_correct = models.BooleanField()
difficulty = models.CharField(max_length=10, blank=True, null=True, choices=[
('again', 'Again'),
('hard', 'Hard'),
('good', 'Good'),
('easy', 'Easy'),
])
answered_at = models.DateTimeField(auto_now_add=True)
class Meta:

View File

@@ -1,13 +1,21 @@
from django.contrib import admin
from django.urls import path
from .views import index, get_next_question, submit_answer, stats, create_quiz
from .views import (
index, get_next_question, submit_answer, stats, create_quiz, close_quiz,
quiz_mode, quiz_question, navigate_question, submit_difficulty
)
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'),
path('quiz/<int:session_id>/', quiz_mode, name='quiz_mode'),
path('quiz/<int:session_id>/question/', quiz_question, name='quiz_question'),
path('quiz/<int:session_id>/<str:direction>/', navigate_question, name='navigate_question'),
path('next/<int:session_id>/', get_next_question, name='next_question'),
path('submit/<int:session_id>/', submit_answer, name='submit_answer'),
path('difficulty/<int:session_id>/', submit_difficulty, name='submit_difficulty'),
path('close/<int:session_id>/', close_quiz, name='close_quiz'),
path('stats/', stats, name='stats'),
path('create/', create_quiz, name='create_quiz'),
]

View File

@@ -4,10 +4,18 @@ from django.urls import reverse
from django.views.decorators.http import require_http_methods
from django.db.models import Q
from .models import Question, QuizResult, Tag
from .models import Question, QuizResult, Tag, Course, Exam, QuizSession
from .forms import CreateQuizForm
@require_http_methods(["POST"])
def close_quiz(request, session_id):
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
session.is_active = False
session.save()
return redirect('index')
def handle_tag_filter(request):
tag_slug = request.GET.get('tag')
if tag_slug is not None:
@@ -21,95 +29,181 @@ def create_quiz(request):
if request.method == 'POST':
form = CreateQuizForm(request.POST)
if form.is_valid():
# clear existing session data
keys_to_clear = ['quiz_filter_course_id', 'quiz_filter_tag_ids', 'quiz_filter_types', 'quiz_tag']
for key in keys_to_clear:
if key in request.session:
del request.session[key]
course = form.cleaned_data.get('course')
exams = form.cleaned_data.get('exams')
tags = form.cleaned_data.get('tags')
question_types = form.cleaned_data.get('question_type')
if course:
request.session['quiz_filter_course_id'] = course.id
q_types = form.cleaned_data.get('question_type')
session = QuizSession.objects.create(
user=request.quiz_user,
course=course,
question_types=q_types if q_types else []
)
if tags:
request.session['quiz_filter_tag_ids'] = list(tags.values_list('id', flat=True))
session.tags.set(tags)
if exams:
session.exams.set(exams)
if question_types:
request.session['quiz_filter_types'] = question_types
return redirect('next_question')
return redirect('index')
else:
form = CreateQuizForm()
return render(request, 'quiz_create.html', {'form': form})
def index(request):
handle_tag_filter(request)
active_sessions = QuizSession.objects.filter(user=request.quiz_user, is_active=True)
total_questions = Question.objects.count()
answered_count = QuizResult.objects.filter(user=request.quiz_user).count()
context = {
'total_questions': total_questions,
'answered_count': answered_count,
'tags': Tag.objects.all(),
'current_tag': request.session.get('quiz_tag'),
'active_sessions': active_sessions,
'form': CreateQuizForm(), # Include form on landing page
}
return render(request, 'index.html', context)
def get_next_question(request):
# Handle tag filtering
handle_tag_filter(request)
current_tag = request.session.get('quiz_tag')
# New filters
filter_course_id = request.session.get('quiz_filter_course_id')
filter_tag_ids = request.session.get('quiz_filter_tag_ids')
filter_types = request.session.get('quiz_filter_types')
def quiz_mode(request, session_id):
"""Dedicated quiz mode view"""
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user, is_active=True)
return render(request, 'quiz_mode.html', {'session': session})
answered_ids = QuizResult.objects.filter(user=request.quiz_user).values_list('question_id', flat=True)
questions = Question.objects.exclude(id__in=answered_ids)
# Apply filters
if current_tag:
questions = questions.filter(tags__slug=current_tag)
if filter_course_id:
questions = questions.filter(exam__course_id=filter_course_id)
if filter_tag_ids:
questions = questions.filter(tags__id__in=filter_tag_ids)
if filter_types:
# "single" -> no comma
# "multi" -> comma
def get_session_questions(session):
"""Helper to get filtered questions for a session"""
questions = Question.objects.all()
if session.course:
questions = questions.filter(exam__course=session.course)
if session.tags.exists():
questions = questions.filter(tags__in=session.tags.all())
if session.exams.exists():
questions = questions.filter(exam__in=session.exams.all())
if session.question_types:
q_objs = Q()
if 'single' in filter_types:
if 'single' in session.question_types:
q_objs |= ~Q(correct_answer__contains=',')
if 'multi' in filter_types:
if 'multi' in session.question_types:
q_objs |= Q(correct_answer__contains=',')
if q_objs:
questions = questions.filter(q_objs)
# Distinguish questions based on filters to ensure we don't get duplicates if filtering by many-to-many
questions = questions.distinct()
next_question = questions.first()
return questions.distinct()
if not next_question:
return render(request, 'partials/complete.html')
return render(request, 'partials/question.html', {'question': next_question})
def quiz_question(request, session_id):
"""Get current question in quiz mode"""
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
# Get all questions for this session
all_questions = get_session_questions(session)
# Get answered questions
answered_ids = QuizResult.objects.filter(
user=request.quiz_user,
quiz_session=session
).values_list('question_id', flat=True)
# Get unanswered questions
unanswered = all_questions.exclude(id__in=answered_ids)
# Default to first unanswered question, or first question if all answered
if unanswered.exists():
question = unanswered.first()
show_answer = False
else:
# All answered, show first question
question = all_questions.first()
if question:
result = QuizResult.objects.filter(
user=request.quiz_user,
quiz_session=session,
question=question
).first()
show_answer = result is not None
else:
return render(request, 'partials/complete.html', {'session': session})
# Calculate navigation
all_q_ids = list(all_questions.values_list('id', flat=True))
current_index = all_q_ids.index(question.id) if question.id in all_q_ids else 0
context = {
'question': question,
'session': session,
'show_answer': show_answer,
'has_previous': current_index > 0,
'has_next': current_index < len(all_q_ids) - 1,
}
if show_answer:
result = QuizResult.objects.get(
user=request.quiz_user,
quiz_session=session,
question=question
)
context['is_correct'] = result.is_correct
return render(request, 'partials/quiz_question.html', context)
def navigate_question(request, session_id, direction):
"""Navigate to previous/next question"""
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
# Get current question from session or query params
current_q_id = request.GET.get('q')
all_questions = get_session_questions(session)
all_q_ids = list(all_questions.values_list('id', flat=True))
if current_q_id:
try:
current_index = all_q_ids.index(int(current_q_id))
except (ValueError, IndexError):
current_index = 0
else:
current_index = 0
# Navigate
if direction == 'previous' and current_index > 0:
new_index = current_index - 1
elif direction == 'next' and current_index < len(all_q_ids) - 1:
new_index = current_index + 1
else:
new_index = current_index
question = all_questions.filter(id=all_q_ids[new_index]).first()
# Check if answered
result = QuizResult.objects.filter(
user=request.quiz_user,
quiz_session=session,
question=question
).first()
context = {
'question': question,
'session': session,
'show_answer': result is not None,
'has_previous': new_index > 0,
'has_next': new_index < len(all_q_ids) - 1,
}
if result:
context['is_correct'] = result.is_correct
return render(request, 'partials/quiz_question.html', context)
@require_http_methods(["POST"])
def submit_answer(request):
def submit_answer(request, session_id):
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
question_id = request.POST.get('question_id')
selected_answer = request.POST.get('answer')
@@ -126,13 +220,93 @@ def submit_answer(request):
QuizResult.objects.update_or_create(
user=request.quiz_user,
question=question,
quiz_session=session,
defaults={
'selected_answer': selected_answer,
'is_correct': is_correct,
}
)
return get_next_question(request)
# Return the same question but with answer shown
all_questions = get_session_questions(session)
all_q_ids = list(all_questions.values_list('id', flat=True))
current_index = all_q_ids.index(question.id) if question.id in all_q_ids else 0
context = {
'question': question,
'session': session,
'show_answer': True,
'is_correct': is_correct,
'has_previous': current_index > 0,
'has_next': current_index < len(all_q_ids) - 1,
}
return render(request, 'partials/quiz_question.html', context)
@require_http_methods(["POST"])
def submit_difficulty(request, session_id):
"""Record difficulty rating for FSRS"""
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
question_id = request.POST.get('question_id')
difficulty = request.POST.get('difficulty')
if not question_id or not difficulty:
return HttpResponse("Invalid submission", status=400)
try:
result = QuizResult.objects.get(
user=request.quiz_user,
quiz_session=session,
question_id=question_id
)
result.difficulty = difficulty
result.save()
return HttpResponse("OK")
except QuizResult.DoesNotExist:
return HttpResponse("Result not found", status=404)
def get_next_question(request, session_id):
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
answered_ids = QuizResult.objects.filter(
user=request.quiz_user,
quiz_session=session
).values_list('question_id', flat=True)
questions = Question.objects.exclude(id__in=answered_ids)
# Apply filters from session
if session.course:
questions = questions.filter(exam__course=session.course)
if session.tags.exists():
questions = questions.filter(tags__in=session.tags.all())
if session.exams.exists():
questions = questions.filter(exam__in=session.exams.all())
if session.question_types:
q_objs = Q()
if 'single' in session.question_types:
q_objs |= ~Q(correct_answer__contains=',')
if 'multi' in session.question_types:
q_objs |= Q(correct_answer__contains=',')
if q_objs:
questions = questions.filter(q_objs)
questions = questions.distinct()
next_question = questions.first()
if not next_question:
return render(request, 'partials/complete.html', {'session': session})
return render(request, 'partials/question.html', {
'question': next_question,
'session': session
})
def stats(request):
@@ -146,4 +320,3 @@ def stats(request):
'percentage': round((correct / total * 100) if total > 0 else 0, 1),
}
return render(request, 'stats.html', context)

View File

@@ -3,19 +3,170 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Quiz</title>
<title>Medical Quiz</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; }
.question { background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0; }
.option { padding: 10px; margin: 5px 0; cursor: pointer; border: 2px solid #ddd; border-radius: 4px; }
.option:hover { background: #e9e9e9; }
.progress { background: #ddd; height: 20px; border-radius: 10px; margin: 20px 0; }
.progress-bar { background: #4CAF50; height: 100%; border-radius: 10px; transition: width 0.3s; }
:root {
--primary: #6366f1;
--primary-hover: #4f46e5;
--bg: #f8fafc;
--card-bg: rgba(255, 255, 255, 0.8);
--text-main: #1e293b;
--text-muted: #64748b;
--border: #e2e8f0;
--glass-bg: rgba(255, 255, 255, 0.7);
--glass-border: rgba(255, 255, 255, 0.3);
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text-main);
line-height: 1.5;
margin: 0;
padding: 0;
min-height: 100vh;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
}
h1, h2, h3 {
font-weight: 700;
margin-top: 0;
}
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
border-radius: 1rem;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
padding: 2rem;
margin-bottom: 2rem;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
text-decoration: none;
font-size: 0.95rem;
}
.btn-primary {
background-color: var(--primary);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
}
.btn-secondary {
background-color: white;
color: var(--text-main);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background-color: #f1f5f9;
}
.btn-danger {
background-color: #ef4444;
color: white;
}
.btn-danger:hover {
background-color: #dc2626;
}
.progress-container {
background: var(--border);
height: 0.5rem;
border-radius: 1rem;
overflow: hidden;
margin: 1rem 0;
}
.progress-bar {
background: var(--primary);
height: 100%;
transition: width 0.3s ease;
}
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
label {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-muted);
}
select, input, textarea {
width: 100%;
padding: 0.625rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background-color: white;
font-family: inherit;
font-size: 0.95rem;
}
/* Grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.session-card {
display: flex;
flex-direction: column;
gap: 1rem;
}
.session-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.session-title {
font-weight: 600;
font-size: 1.1rem;
}
.session-meta {
font-size: 0.85rem;
color: var(--text-muted);
}
</style>
</head>
<body>
{% block content %}{% endblock %}
<div class="container">
{% block content %}{% endblock %}
</div>
</body>
</html>

View File

@@ -1,53 +1,90 @@
{% extends "base.html" %}
{% block content %}
<style>
.filter-section {
margin-bottom: 20px;
}
<div class="header" style="margin-bottom: 3rem; display: flex; justify-content: space-between; align-items: center;">
<div>
<h1 style="font-size: 2.5rem; margin-bottom: 0.5rem;">Välkommen</h1>
<p style="color: var(--text-muted);">Här kan du hantera dina medicinska quiz.</p>
</div>
<div style="text-align: right;">
<div style="font-weight: 600; font-size: 1.25rem;">{{ answered_count }} / {{ total_questions }}</div>
<div style="font-size: 0.875rem; color: var(--text-muted);">Frågor besvarade</div>
<div class="progress-container" style="width: 150px; margin-top: 0.5rem;">
<div class="progress-bar"
style="width: {% if total_questions > 0 %}{{ answered_count|add:0|floatformat:2 }}{% else %}0{% endif %}%">
</div>
</div>
</div>
</div>
.tag-chip {
display: inline-block;
padding: 5px 12px;
margin: 4px;
border-radius: 16px;
background: #e0e0e0;
text-decoration: none;
color: #333;
font-size: 14px;
transition: background 0.2s;
}
<h2 style="margin-bottom: 1.5rem;">Aktiva Quiz</h2>
{% if active_sessions %}
<div class="grid">
{% for session in active_sessions %}
<div class="glass-card session-card" id="session-{{ session.id }}">
<div class="session-header">
<div>
<div class="session-title">
{% if session.course %}{{ session.course.name }}{% else %}Blandat Quiz{% endif %}
</div>
<div class="session-meta">
Startat {{ session.created_at|date:"Y-m-d H:i" }}
</div>
</div>
<form action="{% url 'close_quiz' session.id %}" method="post" hx-post="{% url 'close_quiz' session.id %}"
hx-target="#session-{{ session.id }}" hx-swap="outerHTML">
{% csrf_token %}
<button type="submit" class="btn btn-secondary"
style="padding: 0.25rem 0.5rem; color: #ef4444;">Stäng</button>
</form>
</div>
.tag-chip.active {
background: #4CAF50;
color: white;
}
{% if session.tags.exists %}
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
{% for tag in session.tags.all %}
<span
style="font-size: 0.75rem; background: #e0e7ff; color: #4338ca; padding: 0.25rem 0.5rem; border-radius: 1rem;">{{
tag.name }}</span>
{% endfor %}
</div>
{% endif %}
.tag-chip:hover {
background: #d5d5d5;
}
.tag-chip.active:hover {
background: #45a049;
}
</style>
<h1>Quiz Application</h1>
<div class="filter-section">
<a href="{% url 'create_quiz' %}" class="tag-chip" style="background: #2196F3; color: white;">+ New Quiz</a>
<a href="?tag=" class="tag-chip {% if not current_tag %}active{% endif %}">All</a>
{% for tag in tags %}
<a href="?tag={{ tag.slug }}" class="tag-chip {% if current_tag == tag.slug %}active{% endif %}">
{{ tag.name }}
</a>
<div style="margin-top: auto;">
<a href="{% url 'quiz_mode' session.id %}" class="btn btn-primary" style="width: 100%; text-align: center;">
Fortsätt Quiz
</a>
</div>
</div>
{% endfor %}
</div>
<div class="progress">
<div class="progress-bar"
style="width: {% if total_questions > 0 %}{{ answered_count|floatformat:0 }}{% else %}0{% endif %}%"></div>
{% else %}
<div class="glass-card" style="text-align: center; color: var(--text-muted); padding: 3rem;">
Inga aktiva quiz. Starta ett nytt nedan!
</div>
{% endif %}
<div style="margin-top: 4rem;">
<h2 style="margin-bottom: 1.5rem;">Starta Nytt Quiz</h2>
<div class="glass-card">
<form method="post" action="{% url 'create_quiz' %}">
{% csrf_token %}
<div class="grid">
<div class="form-group">
<label for="{{ form.course.id_for_label }}">Kurs</label>
{{ form.course }}
</div>
<div class="form-group">
<label for="{{ form.tags.id_for_label }}">Taggar</label>
{{ form.tags }}
<small style="color: var(--text-muted); margin-top: 0.25rem; display: block;">Håll ner Ctrl/Cmd för
att välja flera.</small>
</div>
</div>
<div style="margin-top: 1rem;">
<button type="submit" class="btn btn-primary" style="padding-left: 2rem; padding-right: 2rem;">Skapa
Quiz</button>
</div>
</form>
</div>
</div>
<p>Besvarade frågor: {{ answered_count }} / {{ total_questions }}</p>
<div id="quiz-container" hx-get="{% url 'next_question' %}" hx-trigger="load"></div>
{% endblock %}

View File

@@ -1,6 +1,13 @@
<div class="question">
<h2>Quiz Completed!</h2>
<p>Du har besvarat alla frågor.</p>
<a href="{% url 'stats' %}">Se dina resultat</a>
</div>
<div style="text-align: center; padding: 2rem;">
<div style="font-size: 3rem; margin-bottom: 1rem;">🎉</div>
<h2 style="margin-bottom: 1rem;">Quiz Slutfört!</h2>
<p style="color: var(--text-muted); margin-bottom: 2rem;">Bra jobbat! Du har besvarat alla tillgängliga frågor i
detta urval.</p>
<div style="display: flex; gap: 1rem; justify-content: center;">
<a href="{% url 'stats' %}" class="btn btn-secondary">Se Statistik</a>
<form action="{% url 'close_quiz' session.id %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-primary">Avsluta & Gå Tillbaka</button>
</form>
</div>
</div>

View File

@@ -1,14 +1,24 @@
<div class="question">
<h2>{{ question.text }}</h2>
<form hx-post="{% url 'submit_answer' %}" hx-target="#quiz-container">
<div class="quiz-content">
<h3 style="margin-bottom: 1.5rem;">{{ question.text }}</h3>
<form hx-post="{% url 'submit_answer' session.id %}" hx-target="#quiz-container-{{ session.id }}">
{% csrf_token %}
<input type="hidden" name="question_id" value="{{ question.id }}">
{% for option in question.options.all %}
<div class="option" onclick="this.querySelector('input').checked = true; this.closest('form').requestSubmit();">
<input type="radio" name="answer" value="{{ option.letter }}" id="opt_{{ option.letter }}" style="display:none;">
<label for="opt_{{ option.letter }}">{{ option.letter }}. {{ option.text }}</label>
<div style="display: flex; flex-direction: column; gap: 0.75rem;">
{% for option in question.options.all %}
<div class="option"
style="padding: 1rem; border: 1px solid var(--border); border-radius: 0.5rem; cursor: pointer; transition: all 0.2s;"
onmouseover="this.style.backgroundColor='#f1f5f9'" onmouseout="this.style.backgroundColor='transparent'"
onclick="this.querySelector('input').checked = true; this.closest('form').requestSubmit();">
<input type="radio" name="answer" value="{{ option.letter }}"
id="opt_{{ session.id }}_{{ question.id }}_{{ option.letter }}" style="display:none;">
<label style="cursor: pointer; font-weight: 500; color: var(--text-main);"
for="opt_{{ session.id }}_{{ question.id }}_{{ option.letter }}">
<span style="display: inline-block; width: 1.5rem; color: var(--primary); font-weight: 700;">{{
option.letter }}</span>
{{ option.text }}
</label>
</div>
{% endfor %}
</div>
{% endfor %}
</form>
</div>
</div>

View File

@@ -0,0 +1,106 @@
{% if show_answer %}
<div class="answer-feedback {{ 'correct' if is_correct else 'incorrect' }}">
{% if is_correct %}
✓ Rätt svar!
{% else %}
✗ Fel svar. Rätt svar är: {{ question.correct_answer }}
{% endif %}
</div>
{% endif %}
<div class="question-text">{{ question.text }}</div>
<div class="options-container">
{% for option in question.options.all %}
<div class="option-item" id="option-{{ option.letter }}"
onclick="selectOption('{{ option.letter }}', {{ question.id }}, {{ session.id }})">
<span class="option-letter">{{ option.letter }}</span>
<span>{{ option.text }}</span>
</div>
{% endfor %}
</div>
{% if show_answer %}
<div class="difficulty-section">
<div class="difficulty-label">Hur svårt var detta?</div>
<div class="difficulty-buttons">
<button class="difficulty-btn again" onclick="submitDifficulty('again', {{ question.id }}, {{ session.id }})">
<div>Igen</div>
<small style="font-size: 0.75rem; font-weight: 400;">&lt;1m</small>
</button>
<button class="difficulty-btn hard" onclick="submitDifficulty('hard', {{ question.id }}, {{ session.id }})">
<div>Svårt</div>
<small style="font-size: 0.75rem; font-weight: 400;">&lt;6m</small>
</button>
<button class="difficulty-btn good" onclick="submitDifficulty('good', {{ question.id }}, {{ session.id }})">
<div>Bra</div>
<small style="font-size: 0.75rem; font-weight: 400;">&lt;10m</small>
</button>
<button class="difficulty-btn easy" onclick="submitDifficulty('easy', {{ question.id }}, {{ session.id }})">
<div>Lätt</div>
<small style="font-size: 0.75rem; font-weight: 400;">4d</small>
</button>
</div>
</div>
{% endif %}
<div class="nav-buttons">
<button class="nav-btn" {% if not has_previous %}disabled{% endif %}
onclick="navigateQuestion('previous', {{ session.id }})">
← Föregående
</button>
<button class="nav-btn" {% if not has_next %}disabled{% endif %}
onclick="navigateQuestion('next', {{ session.id }})">
Nästa →
</button>
</div>
<script>
let selectedAnswer = null;
function selectOption(letter, questionId, sessionId) {
if ({{ 'true' if show_answer else 'false' }
}) return; // Don't allow changing answer after submission
selectedAnswer = letter;
// Visual feedback
document.querySelectorAll('.option-item').forEach(opt => {
opt.style.borderColor = 'var(--border)';
opt.style.background = 'white';
});
const selected = document.getElementById('option-' + letter);
selected.style.borderColor = 'var(--primary)';
selected.style.background = '#f0f4ff';
// Submit answer
htmx.ajax('POST', `/submit/${sessionId}/`, {
target: '#quiz-content',
values: {
question_id: questionId,
answer: letter
}
});
}
function submitDifficulty(difficulty, questionId, sessionId) {
htmx.ajax('POST', `/difficulty/${sessionId}/`, {
values: {
question_id: questionId,
difficulty: difficulty
}
});
// Move to next question after a brief delay
setTimeout(() => {
navigateQuestion('next', sessionId);
}, 300);
}
function navigateQuestion(direction, sessionId) {
htmx.ajax('GET', `/quiz/${sessionId}/${direction}/`, {
target: '#quiz-content'
});
}
</script>

View File

@@ -0,0 +1,210 @@
{% extends "base.html" %}
{% block content %}
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.quiz-mode-container {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.quiz-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
color: white;
}
.quiz-card {
background: white;
border-radius: 1.5rem;
padding: 3rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
min-height: 400px;
display: flex;
flex-direction: column;
}
.question-text {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 2rem;
line-height: 1.6;
color: var(--text-main);
}
.options-container {
display: flex;
flex-direction: column;
gap: 1rem;
flex-grow: 1;
}
.option-item {
padding: 1.25rem;
border: 2px solid var(--border);
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s;
background: white;
}
.option-item:hover {
border-color: var(--primary);
background: #f8f9fb;
transform: translateX(4px);
}
.option-letter {
display: inline-block;
width: 2rem;
height: 2rem;
background: var(--primary);
color: white;
border-radius: 50%;
text-align: center;
line-height: 2rem;
font-weight: 700;
margin-right: 1rem;
}
.difficulty-section {
margin-top: 2rem;
padding-top: 2rem;
border-top: 2px solid var(--border);
}
.difficulty-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 1rem;
text-align: center;
}
.difficulty-buttons {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
}
.difficulty-btn {
padding: 0.75rem;
border-radius: 0.5rem;
border: 2px solid var(--border);
background: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.difficulty-btn.again {
border-color: #ef4444;
color: #ef4444;
}
.difficulty-btn.again:hover {
background: #ef4444;
color: white;
}
.difficulty-btn.hard {
border-color: #f59e0b;
color: #f59e0b;
}
.difficulty-btn.hard:hover {
background: #f59e0b;
color: white;
}
.difficulty-btn.good {
border-color: #10b981;
color: #10b981;
}
.difficulty-btn.good:hover {
background: #10b981;
color: white;
}
.difficulty-btn.easy {
border-color: #6366f1;
color: #6366f1;
}
.difficulty-btn.easy:hover {
background: #6366f1;
color: white;
}
.nav-buttons {
display: flex;
justify-content: space-between;
margin-top: 2rem;
}
.nav-btn {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
border: none;
background: var(--primary);
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.nav-btn:hover {
background: var(--primary-hover);
}
.nav-btn:disabled {
background: #cbd5e1;
cursor: not-allowed;
}
.answer-feedback {
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
font-weight: 600;
}
.answer-feedback.correct {
background: #d1fae5;
color: #065f46;
border: 2px solid #10b981;
}
.answer-feedback.incorrect {
background: #fee2e2;
color: #991b1b;
border: 2px solid #ef4444;
}
</style>
<div class="quiz-mode-container">
<div class="quiz-header">
<h1 style="margin: 0;">{{ session.course.name|default:"Quiz" }}</h1>
<a href="{% url 'index' %}" class="btn btn-secondary">← Tillbaka till Dashboard</a>
</div>
<div class="quiz-card" id="quiz-content">
<!-- Content loaded via HTMX -->
</div>
</div>
<script>
// Load first question on page load
document.addEventListener('DOMContentLoaded', function () {
htmx.ajax('GET', '{% url 'quiz_question' session.id %}', { target: '#quiz-content' });
});
</script>
{% endblock %}