1
0

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

This commit is contained in:
2025-12-22 13:19:11 +01:00
parent 83c795142f
commit 047414686e
15 changed files with 416 additions and 3730 deletions

View File

@@ -6,7 +6,6 @@
{
"id": "8dd584e60438200b",
"type": "tabs",
"dimension": 62.97968397291196,
"children": [
{
"id": "baa45c5e57825965",
@@ -14,44 +13,13 @@
"state": {
"type": "markdown",
"state": {
"file": "Anatomi & Histologi 2/1 Öga anatomi/Provfrågor.md",
"file": "Anatomi & Histologi 2/Demokompendium.md",
"mode": "source",
"source": false,
"backlinks": false
},
"icon": "lucide-file",
"title": "Provfrågor"
}
}
]
},
{
"id": "7e72057acf1e42f0",
"type": "tabs",
"dimension": 37.020316027088036,
"children": [
{
"id": "9f9fa624da392231",
"type": "leaf",
"state": {
"type": "pdf",
"state": {
"file": "Anatomi & Histologi 2/Gamla tentor/2024-05-29/!2024-05-29-0125-GZX.pdf"
},
"icon": "lucide-file-text",
"title": "!2024-05-29-0125-GZX"
}
},
{
"id": "c1c7815735aa906e",
"type": "leaf",
"state": {
"type": "pdf",
"state": {
"file": "Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf"
},
"icon": "lucide-file-text",
"title": "!2023-05-31-0100-DKS"
"title": "Demokompendium"
}
}
]
@@ -224,10 +192,26 @@
},
"active": "baa45c5e57825965",
"lastOpenFiles": [
"Anatomi & Histologi 2/Gamla tentor/2022-01-15/20.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/20.md",
"Anatomi & Histologi 2/1 Öga anatomi/Provfrågor.md",
"Anatomi & Histologi 2/Gamla tentor/2022-06-01/21.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/19.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0009-RYY.pdf",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf",
"Anatomi & Histologi 2/Gamla tentor/2024-05-29/!2024-05-29-0125-GZX.pdf",
"Anatomi & Histologi 2/1 Öga anatomi/Organa sensum.pdf",
"Anatomi & Histologi 2/1 Öga anatomi/Oculus.md.md",
"Anatomi & Histologi 2/1 Öga anatomi/Målbeskrivning.md",
"Anatomi & Histologi 2/1 Öga anatomi/Instuderingsfrågor.md",
"Anatomi & Histologi 2/1 Öga anatomi/Slides.md",
"Anatomi & Histologi 2/1 Öga anatomi/Video 2.md",
"Anatomi & Histologi 2/1 Öga anatomi/Video 1.md",
"Anatomi & Histologi 2/2 Öra anatomi/Video.md",
"Anatomi & Histologi 2/2 Öra anatomi/Slides.pdf.pdf",
"Anatomi & Histologi 2/2 Öra anatomi/Instuderingsfrågor.md",
"Anatomi & Histologi 2/Schema.md",
"Anatomi & Histologi 2/Statistik.md",
"Anatomi & Histologi 2/Gamla tentor/2022-01-15/20.md",
"Anatomi & Histologi 2/Gamla tentor/2022-06-01/21.md",
"Anatomi & Histologi 2/Gamla tentor/2025-06-03/26.md",
"Anatomi & Histologi 2/Gamla tentor/2025-08-08/10.md",
"Anatomi & Histologi 2/Gamla tentor/2025-08-08/8.md",
@@ -239,28 +223,12 @@
"Anatomi & Histologi 2/Gamla tentor/2025-02-08/21.md",
"Anatomi & Histologi 2/Gamla tentor/2025-01-15/18.md",
"Anatomi & Histologi 2/Gamla tentor/2025-01-15/1.md",
"Anatomi & Histologi 2/Gamla tentor/2024-05-29/12.md",
"Anatomi & Histologi 2/Gamla tentor/2024-05-29/11.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/26.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/20.md",
"Anatomi & Histologi 2/1 Öga anatomi/Video.md",
"Anatomi & Histologi 2/1 Öga anatomi/Slides.pdf.pdf",
"Anatomi & Histologi 2/1 Öga anatomi/Slides.md",
"Anatomi & Histologi 2/1 Öga anatomi/Oculus.md.md",
"Anatomi & Histologi 2/1 Öga anatomi/Målbeskrivning.md",
"Anatomi & Histologi 2/1 Öga anatomi/Instuderingsfrågor.md",
"Anatomi & Histologi 2/Gamla tentor/2024-05-29/22.md",
"Anatomi & Histologi 2/Gamla tentor/2024-05-29/!2024-05-29-0125-GZX.pdf",
"Anatomi & Histologi 2/Gamla tentor/2025-06-03/10.md",
"Biokemi/Metabolism/🍋 Citronsyracykeln/Provfrågor.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/!2023-01-11-0044-PRX.pdf",
"Anatomi & Histologi 2/Gamla tentor/2022-06-01/!2022-06-01-0101-MGY.pdf",
"Anatomi & Histologi 2/Gamla tentor/2022-01-15/!2022-01-15-0032-BWD.pdf",
"attachments/image-121.png",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0009-RYY.pdf",
"z-Tech/tag_exam_questions_v2.sh",
"z-Tech/tag_exam_questions.sh",
"attachments/image-120.png",
"attachments/image-119.png",
"attachments/image-118.png",

View File

@@ -1,5 +1,4 @@
```dataviewjs
for (const path of dv.pagePaths("#provfråga and #öga and #anatomi")) {
dv.span(" \n[[" + path + "]]\n")

File diff suppressed because one or more lines are too long

View File

@@ -11,5 +11,5 @@ Vilken av följande strukturer är transparent? (1p)
- D: Iris
```spoiler-block:
TODO
A
```

View File

@@ -2,7 +2,7 @@
tags: [ah2, provfråga, frågetyp/mcq, anatomi, öga]
date: 2024-01-10
---
I vilken del av retinae återfinner du vare sig tappar eller stavar? (1p)
I vilken del av [retinae](https://en.wikipedia.org/wiki/Retina) återfinner du vare sig tappar eller stavar? (1p)
**Välj ett alternativ:**
- A: Macula lutea

Binary file not shown.

Binary file not shown.

Binary file not shown.

300
quiz/quiz/tests.py Normal file
View File

@@ -0,0 +1,300 @@
from django.test import TestCase, Client
from django.urls import reverse
from quiz.models import Question, QuizUser, QuizSession, QuizResult, Course, Exam, Tag, Option
class QuizViewsTestCase(TestCase):
"""Comprehensive tests for all quiz endpoints"""
def setUp(self):
"""Set up test data"""
self.client = Client()
# Create test user (QuizUser uses session_key, not username)
self.user = QuizUser.objects.create(session_key="test_session_key_123")
# Create test course
self.course = Course.objects.create(name="Test Course")
# Create test exam
self.exam = Exam.objects.create(
name="Test Exam",
course=self.course,
date="2025-01-01" # Required field
)
# Create test tags
self.tag1 = Tag.objects.create(name="Tag 1")
self.tag2 = Tag.objects.create(name="Tag 2")
# Create test questions
self.question1 = Question.objects.create(
text="Test question 1?",
correct_answer="A",
exam=self.exam
)
self.question1.tags.add(self.tag1)
Option.objects.create(question=self.question1, letter="A", text="Answer A")
Option.objects.create(question=self.question1, letter="B", text="Answer B")
self.question2 = Question.objects.create(
text="Test question 2 (multi)?",
correct_answer="A,B",
exam=self.exam
)
self.question2.tags.add(self.tag1, self.tag2)
Option.objects.create(question=self.question2, letter="A", text="Answer A")
Option.objects.create(question=self.question2, letter="B", text="Answer B")
Option.objects.create(question=self.question2, letter="C", text="Answer C")
# Set user in session
session = self.client.session
session['quiz_user_id'] = self.user.id
session.save()
def test_index_view(self):
"""Test dashboard index view"""
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Välkommen')
self.assertIn('active_sessions', response.context)
self.assertIn('form', response.context)
def test_create_quiz(self):
"""Test quiz creation"""
response = self.client.post(reverse('create_quiz'), {
'course': self.course.id,
'tags': [self.tag1.id],
})
self.assertEqual(response.status_code, 302) # Redirect
# Verify session was created
session = QuizSession.objects.filter(user=self.user).first()
self.assertIsNotNone(session)
self.assertEqual(session.course, self.course)
self.assertTrue(session.is_active)
def test_quiz_mode_view(self):
"""Test dedicated quiz mode view"""
session = QuizSession.objects.create(
user=self.user,
course=self.course
)
response = self.client.get(reverse('quiz_mode', args=[session.id]))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Test Course')
self.assertIn('session', response.context)
def test_quiz_question_view(self):
"""Test quiz question endpoint"""
session = QuizSession.objects.create(
user=self.user,
course=self.course
)
response = self.client.get(reverse('quiz_question', args=[session.id]))
self.assertEqual(response.status_code, 200)
self.assertIn('question', response.context)
self.assertIn('session', response.context)
def test_navigate_question(self):
"""Test question navigation"""
session = QuizSession.objects.create(
user=self.user,
course=self.course
)
# Test next navigation
response = self.client.get(
reverse('navigate_question', args=[session.id, 'next']),
{'q': self.question1.id}
)
self.assertEqual(response.status_code, 200)
# Test previous navigation
response = self.client.get(
reverse('navigate_question', args=[session.id, 'previous']),
{'q': self.question2.id}
)
self.assertEqual(response.status_code, 200)
def test_submit_single_answer(self):
"""Test submitting a single-choice answer"""
session = QuizSession.objects.create(
user=self.user,
course=self.course
)
response = self.client.post(
reverse('submit_answer', args=[session.id]),
{
'question_id': self.question1.id,
'answer': 'A'
}
)
self.assertEqual(response.status_code, 200)
# Verify result was created
result = QuizResult.objects.get(
user=self.user,
question=self.question1,
quiz_session=session
)
self.assertEqual(result.selected_answer, 'A')
self.assertTrue(result.is_correct)
def test_submit_multi_answer(self):
"""Test submitting a multi-choice answer"""
session = QuizSession.objects.create(
user=self.user,
course=self.course
)
# Test correct multi-answer (order shouldn't matter)
response = self.client.post(
reverse('submit_answer', args=[session.id]),
{
'question_id': self.question2.id,
'answer': 'B,A' # Reversed order
}
)
self.assertEqual(response.status_code, 200)
result = QuizResult.objects.get(
user=self.user,
question=self.question2,
quiz_session=session
)
self.assertTrue(result.is_correct) # Should be correct despite order
def test_submit_difficulty(self):
"""Test submitting FSRS difficulty rating"""
session = QuizSession.objects.create(
user=self.user,
course=self.course
)
# First submit an answer
QuizResult.objects.create(
user=self.user,
question=self.question1,
quiz_session=session,
selected_answer='A',
is_correct=True
)
# Then submit difficulty
response = self.client.post(
reverse('submit_difficulty', args=[session.id]),
{
'question_id': self.question1.id,
'difficulty': 'easy'
}
)
self.assertEqual(response.status_code, 200)
# Verify difficulty was saved
result = QuizResult.objects.get(
user=self.user,
question=self.question1,
quiz_session=session
)
self.assertEqual(result.difficulty, 'easy')
def test_close_quiz(self):
"""Test closing a quiz session"""
session = QuizSession.objects.create(
user=self.user,
course=self.course,
is_active=True
)
response = self.client.post(reverse('close_quiz', args=[session.id]))
self.assertEqual(response.status_code, 302) # Redirect to index
# Verify session was deactivated
session.refresh_from_db()
self.assertFalse(session.is_active)
def test_invalid_session_access(self):
"""Test accessing another user's session"""
other_user = QuizUser.objects.create(session_key="other_session_key")
session = QuizSession.objects.create(
user=other_user,
course=self.course
)
# Try to access it
response = self.client.get(reverse('quiz_mode', args=[session.id]))
self.assertEqual(response.status_code, 404)
def test_answer_without_question_id(self):
"""Test error handling for missing question_id"""
session = QuizSession.objects.create(
user=self.user,
course=self.course
)
response = self.client.post(
reverse('submit_answer', args=[session.id]),
{'answer': 'A'} # Missing question_id
)
self.assertEqual(response.status_code, 400)
def test_difficulty_without_result(self):
"""Test error handling for difficulty without existing result"""
session = QuizSession.objects.create(
user=self.user,
course=self.course
)
response = self.client.post(
reverse('submit_difficulty', args=[session.id]),
{
'question_id': self.question1.id,
'difficulty': 'easy'
}
)
self.assertEqual(response.status_code, 404)
def test_answer_normalization(self):
"""Test that multi-choice answers are normalized correctly"""
session = QuizSession.objects.create(
user=self.user,
course=self.course
)
# Submit answers in different orders
test_cases = [
('A,B', True), # Correct order
('B,A', True), # Reversed order
('A,C', False), # Wrong answer
('A', False), # Incomplete answer
]
for answer, expected_correct in test_cases:
result = QuizResult.objects.filter(
user=self.user,
question=self.question2,
quiz_session=session
).delete() # Clean up
response = self.client.post(
reverse('submit_answer', args=[session.id]),
{
'question_id': self.question2.id,
'answer': answer
}
)
self.assertEqual(response.status_code, 200)
result = QuizResult.objects.get(
user=self.user,
question=self.question2,
quiz_session=session
)
self.assertEqual(result.is_correct, expected_correct,
f"Answer '{answer}' should be {'correct' if expected_correct else 'incorrect'}")

View File

@@ -215,7 +215,13 @@ def submit_answer(request, session_id):
except Question.DoesNotExist:
return HttpResponse("Question not found", status=404)
is_correct = selected_answer == question.correct_answer
# Normalize answers for comparison (sort comma-separated values)
def normalize_answer(ans):
if ',' in ans:
return ','.join(sorted(ans.split(',')))
return ans
is_correct = normalize_answer(selected_answer) == normalize_answer(question.correct_answer)
QuizResult.objects.update_or_create(
user=request.quiz_user,

View File

@@ -1,49 +1,19 @@
{% if show_answer %}
<div class="answer-feedback {% if is_correct %}correct{% else %}incorrect{% endif %}">
{% if is_correct %}
✓ Rätt svar!
{% else %}
✗ Fel svar. Rätt svar är: {{ question.correct_answer }}
{% endif %}
</div>
{% endif %}
{% csrf_token %}
<input type="hidden" id="current-question-id" value="{{ question.id }}">
<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 }})">
<div class="option-item" id="option-{{ option.letter }}" onclick="toggleOption('{{ option.letter }}')">
<input type="checkbox" id="checkbox-{{ option.letter }}\" value="{{ option.letter }}"
style="margin-right: 0.5rem; width: 1.2rem; height: 1.2rem; cursor: pointer;">
<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 }})">
@@ -53,55 +23,4 @@
onclick="navigateQuestion('next', {{ session.id }})">
Nästa →
</button>
</div>
<script>
let selectedAnswer = null;
function selectOption(letter, questionId, sessionId) {
{% if show_answer %}
return; // Don't allow changing answer after submission
{% endif %}
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>
</div>

View File

@@ -52,6 +52,8 @@
cursor: pointer;
transition: all 0.2s;
background: white;
display: flex;
align-items: center;
}
.option-item:hover {
@@ -70,78 +72,7 @@
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;
margin-left: 0.5rem;
}
.nav-buttons {
@@ -169,25 +100,6 @@
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">
@@ -202,9 +114,82 @@
</div>
<script>
// Load first question on page load
const SESSION_ID = parseInt("{{ session.id }}");
function saveAnswer(questionId, selectedLetters) {
const key = 'quiz_' + SESSION_ID + '_answers';
const answers = JSON.parse(localStorage.getItem(key) || '{}');
answers[questionId] = selectedLetters;
localStorage.setItem(key, JSON.stringify(answers));
}
function loadAnswer(questionId) {
const key = 'quiz_' + SESSION_ID + '_answers';
const answers = JSON.parse(localStorage.getItem(key) || '{}');
return answers[questionId] || [];
}
function toggleOption(letter) {
const checkbox = document.getElementById('checkbox-' + letter);
const optionDiv = document.getElementById('option-' + letter);
if (!checkbox || !optionDiv) return;
checkbox.checked = !checkbox.checked;
if (checkbox.checked) {
optionDiv.style.borderColor = 'var(--primary)';
optionDiv.style.background = '#f0f4ff';
} else {
optionDiv.style.borderColor = 'var(--border)';
optionDiv.style.background = 'white';
}
const questionId = document.getElementById('current-question-id').value;
const checkboxes = document.querySelectorAll('input[type="checkbox"]:checked');
const selectedLetters = Array.from(checkboxes).map(cb => cb.value).sort();
saveAnswer(questionId, selectedLetters);
}
function restoreAnswers() {
const questionId = document.getElementById('current-question-id')?.value;
if (!questionId) return;
const saved = loadAnswer(questionId);
saved.forEach(letter => {
const checkbox = document.getElementById('checkbox-' + letter);
const optionDiv = document.getElementById('option-' + letter);
if (checkbox && optionDiv) {
checkbox.checked = true;
optionDiv.style.borderColor = 'var(--primary)';
optionDiv.style.background = '#f0f4ff';
}
});
}
function navigateQuestion(direction, sessionId) {
const currentQInput = document.getElementById('current-question-id');
const currentQ = currentQInput ? currentQInput.value : null;
const url = currentQ
? '/quiz/' + sessionId + '/' + direction + '/?q=' + currentQ
: '/quiz/' + sessionId + '/' + direction + '/';
htmx.ajax('GET', url, {
target: '#quiz-content'
});
}
const observer = new MutationObserver(() => {
restoreAnswers();
});
observer.observe(document.getElementById('quiz-content'), {
childList: true,
subtree: true
});
document.addEventListener('DOMContentLoaded', function () {
htmx.ajax('GET', '{% url 'quiz_question' session.id %}', { target: '#quiz-content' });
setTimeout(restoreAnswers, 100);
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff