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

View File

@@ -5,6 +5,7 @@ Avoid extra new lines, prefer fewer new lines and compact formatting.
# Coding # Coding
The rest of this document describes coding conventions to follow when writing code. The rest of this document describes coding conventions to follow when writing code.
## General ## General
- Do not create comments nor docstrings when updating code unless asked. - Do not create comments nor docstrings when updating code unless asked.
- Do not create a summary .md file unless asked. - Do not create a summary .md file unless asked.
@@ -12,6 +13,8 @@ The rest of this document describes coding conventions to follow when writing co
- When installing a new dependency, prefer to use the latest version. - When installing a new dependency, prefer to use the latest version.
## Python ## Python
- Use uv for handling python installations
- Use pyproject.toml to handle dependencies
- Avoid exceptions if possible - Avoid exceptions if possible
- When exceptions are necessary, use specific exception types, provide meaningful messages, and handle them appropriately. - When exceptions are necessary, use specific exception types, provide meaningful messages, and handle them appropriately.
- Exceptions try/except blocks should be as narrow as possible, try extra hard to avoid catching exceptions you did not intend to catch. - Exceptions try/except blocks should be as narrow as possible, try extra hard to avoid catching exceptions you did not intend to catch.
@@ -19,6 +22,7 @@ The rest of this document describes coding conventions to follow when writing co
## Python unit testing: ## Python unit testing:
- Use pytest framework, version 9 or higher. - Use pytest framework, version 9 or higher.
- Configure using pyproject.toml, avoid pytest.ini to be able to have all configuration in one place.
- Prefer parametrized tests for functions that need to be tested with multiple sets of inputs and expected outputs. - Prefer parametrized tests for functions that need to be tested with multiple sets of inputs and expected outputs.
- Use the new `with pytest.test(...)´ for that purpose: - Use the new `with pytest.test(...)´ for that purpose:
@@ -32,3 +36,9 @@ def test_parametrized(subtests: pytest.Subtests) -> None:
assert ... assert ...
# ... tear down ... # ... tear down ...
``` ```
## Django
- Prefer function based views over class based views
- Use django-stubs for type hints
- Use pytest-django for testing django applications

View File

@@ -1,105 +1,65 @@
{ {
"main": { "main": {
"id": "5c3e9508c9a74b0c", "id": "19179b278823b064",
"type": "split", "type": "split",
"children": [ "children": [
{ {
"id": "b0a29407702ad8d9", "id": "8dd584e60438200b",
"type": "tabs", "type": "tabs",
"children": [ "children": [
{ {
"id": "09f0d52982024b18", "id": "baa45c5e57825965",
"type": "leaf",
"state": {
"type": "split-diff-view",
"state": {
"aFile": "content/.obsidian/plugins/obsidian-icon-folder/data.json",
"bFile": "content/.obsidian/plugins/obsidian-icon-folder/data.json",
"aRef": ""
},
"icon": "diff",
"title": "Diff: data.json"
}
},
{
"id": "1719ec1bea5bf182",
"type": "leaf", "type": "leaf",
"state": { "state": {
"type": "markdown", "type": "markdown",
"state": { "state": {
"file": "Biokemi/Behöver göra.md", "file": "Anatomi & Histologi 2/1 Öga anatomi/Video.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "Behöver göra"
}
},
{
"id": "ef5b64bffe19b453",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Biokemi/Gamla tentor/2024-01-27/29.md",
"mode": "source",
"source": false
},
"icon": "lucide-file",
"title": "29"
}
},
{
"id": "0ee86d2943a05d44",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Biokemi/Metabolism/🩸 Heme/Slides.md",
"mode": "source", "mode": "source",
"source": false, "source": false,
"backlinks": false "backlinks": false
}, },
"icon": "lucide-file", "icon": "lucide-file",
"title": "Slides" "title": "Video"
} }
}
]
}, },
{ {
"id": "f5463f24c8d28b65", "id": "7e72057acf1e42f0",
"type": "tabs",
"children": [
{
"id": "c1c7815735aa906e",
"type": "leaf", "type": "leaf",
"state": { "state": {
"type": "markdown", "type": "pdf",
"state": { "state": {
"file": "Anatomi & Histologi 2/Schema.md", "file": "Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf"
"mode": "source",
"source": false,
"backlinks": false
}, },
"icon": "lucide-file", "icon": "lucide-file-text",
"title": "Schema" "title": "!2023-05-31-0100-DKS"
} }
} }
], ]
"currentTab": 4
} }
], ],
"direction": "vertical" "direction": "vertical"
}, },
"left": { "left": {
"id": "2eb9d01da512f2e5", "id": "70dc58e919eddd95",
"type": "split", "type": "split",
"children": [ "children": [
{ {
"id": "7f1ec433ebd415ff", "id": "47a30d427cdfb6db",
"type": "tabs", "type": "tabs",
"children": [ "children": [
{ {
"id": "14b12303d7c7ac64", "id": "ef51d026ab2efaae",
"type": "leaf", "type": "leaf",
"state": { "state": {
"type": "file-explorer", "type": "file-explorer",
"state": { "state": {
"sortOrder": "alphabetical", "sortOrder": "custom",
"autoReveal": false "autoReveal": false
}, },
"icon": "lucide-folder-closed", "icon": "lucide-folder-closed",
@@ -107,68 +67,58 @@
} }
}, },
{ {
"id": "500d71ff4998df8b", "id": "eafa93eb6a28e671",
"type": "leaf", "type": "leaf",
"state": { "state": {
"type": "search", "type": "search",
"state": { "state": {
"query": "tag:#provfråga lager ", "query": "spoiler-",
"matchingCase": false, "matchingCase": false,
"explainSearch": false, "explainSearch": false,
"collapseAll": false, "collapseAll": false,
"extraContext": false, "extraContext": true,
"sortOrder": "alphabetical" "sortOrder": "byModifiedTime"
}, },
"icon": "lucide-search", "icon": "lucide-search",
"title": "Search" "title": "Search"
} }
},
{
"id": "0f714096e178591e",
"type": "leaf",
"state": {
"type": "bookmarks",
"state": {},
"icon": "lucide-ghost",
"title": "bookmarks"
}
} }
] ]
} }
], ],
"direction": "horizontal", "direction": "horizontal",
"width": 468.50312423706055 "width": 200
}, },
"right": { "right": {
"id": "2d28508435d0e696", "id": "0948c66181b40af9",
"type": "split", "type": "split",
"children": [ "children": [
{ {
"id": "65c571801186bf7b", "id": "8e42749b81d80f27",
"type": "tabs", "type": "tabs",
"children": [ "children": [
{ {
"id": "8827fad1b6f2c177", "id": "131da419ce467615",
"type": "leaf", "type": "leaf",
"state": { "state": {
"type": "outgoing-link", "type": "outgoing-link",
"state": { "state": {
"file": "Anatomi & Histologi 2/Schema.md", "file": "Biokemi/Plasmidlabb/Provfrågor.md",
"linksCollapsed": false, "linksCollapsed": false,
"unlinkedCollapsed": true "unlinkedCollapsed": true
}, },
"icon": "links-going-out", "icon": "links-going-out",
"title": "Outgoing links from Schema" "title": "Outgoing links from Provfrågor"
} }
}, },
{ {
"id": "74b9983ea2c313f7", "id": "5c1804c056cc2e31",
"type": "leaf", "type": "leaf",
"state": { "state": {
"type": "tag", "type": "tag",
"state": { "state": {
"sortOrder": "frequency", "sortOrder": "frequency",
"useHierarchy": true, "useHierarchy": false,
"showSearch": true, "showSearch": true,
"searchQuery": "" "searchQuery": ""
}, },
@@ -177,7 +127,22 @@
} }
}, },
{ {
"id": "758d6d430e3ef7a0", "id": "d4a03ebd29e7b96c",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"file": "Biokemi/Cellulära processer/Translation/Stoff.md",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "Outline of Stoff"
}
},
{
"id": "a23e068aac24f909",
"type": "leaf", "type": "leaf",
"state": { "state": {
"type": "all-properties", "type": "all-properties",
@@ -191,22 +156,7 @@
} }
}, },
{ {
"id": "1c46c6d504021aa4", "id": "41f1a2a8dc1c3ad7",
"type": "leaf",
"state": {
"type": "outline",
"state": {
"file": "Biokemi/Cellulära processer/Utforska proteiner/Slides.md",
"followCursor": false,
"showSearch": false,
"searchQuery": ""
},
"icon": "lucide-list",
"title": "Outline of Slides"
}
},
{
"id": "3d06ebe3b85788d4",
"type": "leaf", "type": "leaf",
"state": { "state": {
"type": "git-view", "type": "git-view",
@@ -214,80 +164,102 @@
"icon": "git-pull-request", "icon": "git-pull-request",
"title": "Source Control" "title": "Source Control"
} }
},
{
"id": "42527268e0f60e4a",
"type": "leaf",
"state": {
"type": "file-properties",
"state": {
"file": "Anatomi & Histologi 2/Gamla tentor/Statistik.md"
},
"icon": "lucide-info",
"title": "File properties for Statistik"
}
},
{
"id": "03afb117002fcdff",
"type": "leaf",
"state": {
"type": "agent-client-chat-view",
"state": {},
"icon": "bot-message-square",
"title": "Agent client"
}
} }
], ],
"currentTab": 4 "currentTab": 4
} }
], ],
"direction": "horizontal", "direction": "horizontal",
"width": 423.5 "width": 212.5
}, },
"left-ribbon": { "left-ribbon": {
"hiddenItems": { "hiddenItems": {
"agent-client:Open agent client": false, "obsidian42-brat:BRAT": false,
"switcher:Open quick switcher": false, "switcher:Open quick switcher": false,
"graph:Open graph view": false,
"canvas:Create new canvas": false,
"templates:Insert template": false,
"command-palette:Open command palette": false, "command-palette:Open command palette": false,
"bases:Create new base": false,
"obsidian-git:Open Git source control": false, "obsidian-git:Open Git source control": false,
"omnisearch:Omnisearch": false, "omnisearch:Omnisearch": false,
"obsidian42-brat:BRAT": false "bases:Create new base": false,
"canvas:Create new canvas": false,
"graph:Open graph view": false,
"templates:Insert template": false,
"agent-client:Open agent client": false
} }
}, },
"active": "f5463f24c8d28b65", "active": "baa45c5e57825965",
"lastOpenFiles": [ "lastOpenFiles": [
"test_folder",
"conflict-files-obsidian-git.md",
"Anatomi & Histologi 2/Schema.md", "Anatomi & Histologi 2/Schema.md",
"Anatomi & Histologi 2/1 Öga anatomi/Video.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/8.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/7.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/6.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/5.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/4.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/3.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/2.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/1.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf",
"Anatomi & Histologi 2/Statistik.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/1.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/29.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/30.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/28.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/27.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/26.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/!2023-01-11-0044-PRX.pdf",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/25.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/24.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/23.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/22.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/21.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/20.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/19.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/18.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/17.md",
"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_v2.sh",
"z-Tech/tag_exam_questions.sh", "z-Tech/tag_exam_questions.sh",
"z-Tech/Quiz-app.md",
"z-Tech/Obsidian AI.md",
"attachments/image.png",
"attachments/image-99.png",
"attachments/image-98.png",
"attachments/image-97.png",
"attachments/image-96.png",
"attachments/image-95.png",
"attachments/image-94.png",
"attachments/image-93.png",
"attachments/image-92.png",
"attachments/image-91.png",
"Anatomi & Histologi 2/Statistik.md",
"Anatomi & Histologi 2/Gamla tentor/2025-08-08/!2025-08-08-0030-SHJ.pdf", "Anatomi & Histologi 2/Gamla tentor/2025-08-08/!2025-08-08-0030-SHJ.pdf",
"Anatomi & Histologi 2/Gamla tentor/2025-08-08/!2025-08-08-0030-SHJ.md", "attachments/image-120.png",
"attachments/image-119.png",
"attachments/image-118.png",
"attachments/image-117.png",
"attachments/image-116.png",
"attachments/image-115.png",
"attachments/image-114.png",
"attachments/image-113.png",
"attachments/image-112.png",
"Anatomi & Histologi 2/Gamla tentor/2025-06-03/!2025-06-03-0003-UJR.pdf", "Anatomi & Histologi 2/Gamla tentor/2025-06-03/!2025-06-03-0003-UJR.pdf",
"Anatomi & Histologi 2/Gamla tentor/2025-06-03/!2025-06-03-0003-UJR.md",
"Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08.md",
"Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08-0004-PNZ.pdf",
"Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08-0003-ESW.pdf", "Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08-0003-ESW.pdf",
"Anatomi & Histologi 2/Gamla tentor/2025-02-08/!2025-02-08-0003-ESW.md",
"Anatomi & Histologi 2/Gamla tentor/2025-01-15/!2025-01-15-0024-HFS.pdf",
"Anatomi & Histologi 2/Gamla tentor/2025-01-15/!2025-01-15-0021-HRY.pdf",
"Anatomi & Histologi 2/Gamla tentor/2025-01-15/!2025-01-15-0021-HRY.md",
"Anatomi & Histologi 2/Gamla tentor/2024-05-29/!2024-05-29-0125-GZX.pdf",
"Anatomi & Histologi 2/Gamla tentor/2024-05-29/!2024-05-29-0125-GZX.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0088-KOM.pdf",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0088-KOM.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.md",
"Anatomi & Histologi 2/Gamla tentor/2023-01-11/!2023-01-11-0044-PRX.md",
"Anatomi & Histologi 2/Gamla tentor/2022-06-01/!2022-06-01-0101-MGY.md",
"Anatomi & Histologi 2/Gamla tentor/2022-01-15/!2022-01-15-0032-BWD.md",
"Anatomi & Histologi 2/4 Öra histologi/Målbeskrivning.md",
"Anatomi & Histologi 2/4 Öra histologi/Instuderingsfrågor.md",
"Anatomi & Histologi 2/4 Öra histologi/25 Ear.md",
"Anatomi & Histologi 2/3 Öga histologi/Slides.md",
"Anatomi & Histologi 2/3 Öga histologi/Målbeskrivning.md",
"Anatomi & Histologi 2/3 Öga histologi/Instuderingsfrågor.md",
"Anatomi & Histologi 2/3 Öga histologi/24 Eye.md",
"Anatomi & Histologi 2/2 Öra anatomi/Slides.md",
"Anatomi & Histologi 2/2 Öra anatomi/Målbeskrivning.md",
"Untitled.canvas", "Untitled.canvas",
"Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas", "Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas",
"Biokemi/Metabolism/📋 Metabolismen översikt.canvas", "Biokemi/Metabolism/📋 Metabolismen översikt.canvas",
"Biokemi/Metabolism/Metabolismen översikt.canvas", "🧪 Biokemi/🏋️‍♀️ Metabolism/📋 Metabolismen översikt.canvas",
"Biokemi/Metabolism/Introduktion till metabolismen/Untitled.canvas" "🧪 Biokemi/🏋️‍♀️ Metabolism/👋 Introduktion till metabolismen/Untitled.canvas"
] ]
} }

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -1,26 +1,35 @@
from django import forms from django import forms
from django.db.models import Count from django.db.models import Count
from .models import Course, Tag from .models import Course, Tag, Exam
class TagModelMultipleChoiceField(forms.ModelMultipleChoiceField): class TagModelMultipleChoiceField(forms.ModelMultipleChoiceField):
def label_from_instance(self, obj): def label_from_instance(self, obj):
return f"{obj.name} ({obj.question_count})" 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): class CreateQuizForm(forms.Form):
course = forms.ModelChoiceField( course = forms.ModelChoiceField(
queryset=Course.objects.all(), queryset=Course.objects.all(),
required=False, required=False,
empty_label="All Courses", empty_label="Alla kurser",
widget=forms.Select(attrs={'class': 'form-control'}) widget=forms.Select(attrs={'class': 'form-control'})
) )
exams = ExamModelMultipleChoiceField(
queryset=Exam.objects.all(),
required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-control'})
)
tags = TagModelMultipleChoiceField( tags = TagModelMultipleChoiceField(
queryset=Tag.objects.annotate(question_count=Count('questions')).order_by('name'), queryset=Tag.objects.annotate(question_count=Count('questions')).order_by('name'),
required=False, required=False,
widget=forms.SelectMultiple(attrs={'class': 'form-control'}) widget=forms.SelectMultiple(attrs={'class': 'form-control'})
) )
QUESTION_TYPES = [ QUESTION_TYPES = [
('single', 'Single Choice'), ('single', 'Envalsfrågor'),
('multi', 'Multiple Choice'), ('multi', 'Flervalsfrågor'),
] ]
question_type = forms.MultipleChoiceField( question_type = forms.MultipleChoiceField(
choices=QUESTION_TYPES, 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]}" 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): class QuizResult(models.Model):
user = models.ForeignKey(QuizUser, on_delete=models.CASCADE, related_name='results') 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) question = models.ForeignKey(Question, on_delete=models.CASCADE)
selected_answer = models.CharField(max_length=1) selected_answer = models.CharField(max_length=1)
is_correct = models.BooleanField() 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) answered_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:

View File

@@ -1,13 +1,21 @@
from django.contrib import admin from django.contrib import admin
from django.urls import path 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 = [ urlpatterns = [
path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('', index, name='index'), path('', index, name='index'),
path('next/', get_next_question, name='next_question'), path('quiz/<int:session_id>/', quiz_mode, name='quiz_mode'),
path('submit/', submit_answer, name='submit_answer'), 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('stats/', stats, name='stats'),
path('create/', create_quiz, name='create_quiz'), 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.views.decorators.http import require_http_methods
from django.db.models import Q 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 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): def handle_tag_filter(request):
tag_slug = request.GET.get('tag') tag_slug = request.GET.get('tag')
if tag_slug is not None: if tag_slug is not None:
@@ -21,95 +29,181 @@ def create_quiz(request):
if request.method == 'POST': if request.method == 'POST':
form = CreateQuizForm(request.POST) form = CreateQuizForm(request.POST)
if form.is_valid(): 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') course = form.cleaned_data.get('course')
exams = form.cleaned_data.get('exams')
tags = form.cleaned_data.get('tags') tags = form.cleaned_data.get('tags')
question_types = form.cleaned_data.get('question_type') q_types = form.cleaned_data.get('question_type')
if course:
request.session['quiz_filter_course_id'] = course.id
session = QuizSession.objects.create(
user=request.quiz_user,
course=course,
question_types=q_types if q_types else []
)
if tags: 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: return redirect('index')
request.session['quiz_filter_types'] = question_types
return redirect('next_question')
else: else:
form = CreateQuizForm() form = CreateQuizForm()
return render(request, 'quiz_create.html', {'form': form}) return render(request, 'quiz_create.html', {'form': form})
def index(request): def index(request):
handle_tag_filter(request) active_sessions = QuizSession.objects.filter(user=request.quiz_user, is_active=True)
total_questions = Question.objects.count() total_questions = Question.objects.count()
answered_count = QuizResult.objects.filter(user=request.quiz_user).count() answered_count = QuizResult.objects.filter(user=request.quiz_user).count()
context = { context = {
'total_questions': total_questions, 'total_questions': total_questions,
'answered_count': answered_count, 'answered_count': answered_count,
'tags': Tag.objects.all(), 'active_sessions': active_sessions,
'current_tag': request.session.get('quiz_tag'), 'form': CreateQuizForm(), # Include form on landing page
} }
return render(request, 'index.html', context) return render(request, 'index.html', context)
def get_next_question(request): def quiz_mode(request, session_id):
# Handle tag filtering """Dedicated quiz mode view"""
handle_tag_filter(request) session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user, is_active=True)
return render(request, 'quiz_mode.html', {'session': session})
current_tag = request.session.get('quiz_tag')
# New filters def get_session_questions(session):
filter_course_id = request.session.get('quiz_filter_course_id') """Helper to get filtered questions for a session"""
filter_tag_ids = request.session.get('quiz_filter_tag_ids') questions = Question.objects.all()
filter_types = request.session.get('quiz_filter_types')
answered_ids = QuizResult.objects.filter(user=request.quiz_user).values_list('question_id', flat=True) if session.course:
questions = questions.filter(exam__course=session.course)
questions = Question.objects.exclude(id__in=answered_ids) if session.tags.exists():
questions = questions.filter(tags__in=session.tags.all())
# Apply filters if session.exams.exists():
if current_tag: questions = questions.filter(exam__in=session.exams.all())
questions = questions.filter(tags__slug=current_tag)
if filter_course_id: if session.question_types:
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
q_objs = Q() q_objs = Q()
if 'single' in filter_types: if 'single' in session.question_types:
q_objs |= ~Q(correct_answer__contains=',') q_objs |= ~Q(correct_answer__contains=',')
if 'multi' in filter_types: if 'multi' in session.question_types:
q_objs |= Q(correct_answer__contains=',') q_objs |= Q(correct_answer__contains=',')
if q_objs: if q_objs:
questions = questions.filter(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 return questions.distinct()
questions = questions.distinct()
next_question = questions.first()
if not next_question: def quiz_question(request, session_id):
return render(request, 'partials/complete.html') """Get current question in quiz mode"""
session = get_object_or_404(QuizSession, id=session_id, user=request.quiz_user)
return render(request, 'partials/question.html', {'question': next_question}) # 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"]) @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') question_id = request.POST.get('question_id')
selected_answer = request.POST.get('answer') selected_answer = request.POST.get('answer')
@@ -126,13 +220,93 @@ def submit_answer(request):
QuizResult.objects.update_or_create( QuizResult.objects.update_or_create(
user=request.quiz_user, user=request.quiz_user,
question=question, question=question,
quiz_session=session,
defaults={ defaults={
'selected_answer': selected_answer, 'selected_answer': selected_answer,
'is_correct': is_correct, '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): def stats(request):
@@ -146,4 +320,3 @@ def stats(request):
'percentage': round((correct / total * 100) if total > 0 else 0, 1), 'percentage': round((correct / total * 100) if total > 0 else 0, 1),
} }
return render(request, 'stats.html', context) return render(request, 'stats.html', context)

View File

@@ -3,19 +3,170 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <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> <script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style> <style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; } :root {
.question { background: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0; } --primary: #6366f1;
.option { padding: 10px; margin: 5px 0; cursor: pointer; border: 2px solid #ddd; border-radius: 4px; } --primary-hover: #4f46e5;
.option:hover { background: #e9e9e9; } --bg: #f8fafc;
.progress { background: #ddd; height: 20px; border-radius: 10px; margin: 20px 0; } --card-bg: rgba(255, 255, 255, 0.8);
.progress-bar { background: #4CAF50; height: 100%; border-radius: 10px; transition: width 0.3s; } --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> </style>
</head> </head>
<body> <body>
<div class="container">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div>
</body> </body>
</html> </html>

View File

@@ -1,53 +1,90 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<style> <div class="header" style="margin-bottom: 3rem; display: flex; justify-content: space-between; align-items: center;">
.filter-section { <div>
margin-bottom: 20px; <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 { <h2 style="margin-bottom: 1.5rem;">Aktiva Quiz</h2>
display: inline-block; {% if active_sessions %}
padding: 5px 12px; <div class="grid">
margin: 4px; {% for session in active_sessions %}
border-radius: 16px; <div class="glass-card session-card" id="session-{{ session.id }}">
background: #e0e0e0; <div class="session-header">
text-decoration: none; <div>
color: #333; <div class="session-title">
font-size: 14px; {% if session.course %}{{ session.course.name }}{% else %}Blandat Quiz{% endif %}
transition: background 0.2s; </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 { {% if session.tags.exists %}
background: #4CAF50; <div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
color: white; {% 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-chip:hover { tag.name }}</span>
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>
{% endfor %} {% endfor %}
</div> </div>
{% endif %}
<div class="progress"> <div style="margin-top: auto;">
<div class="progress-bar" <a href="{% url 'quiz_mode' session.id %}" class="btn btn-primary" style="width: 100%; text-align: center;">
style="width: {% if total_questions > 0 %}{{ answered_count|floatformat:0 }}{% else %}0{% endif %}%"></div> Fortsätt Quiz
</a>
</div>
</div>
{% endfor %}
</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> </div>
<p>Besvarade frågor: {{ answered_count }} / {{ total_questions }}</p>
<div id="quiz-container" hx-get="{% url 'next_question' %}" hx-trigger="load"></div>
{% endblock %} {% endblock %}

View File

@@ -1,6 +1,13 @@
<div class="question"> <div style="text-align: center; padding: 2rem;">
<h2>Quiz Completed!</h2> <div style="font-size: 3rem; margin-bottom: 1rem;">🎉</div>
<p>Du har besvarat alla frågor.</p> <h2 style="margin-bottom: 1rem;">Quiz Slutfört!</h2>
<a href="{% url 'stats' %}">Se dina resultat</a> <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> </div>

View File

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

3175
wip/89xdN2rC94A.sv.srt Normal file

File diff suppressed because it is too large Load Diff

2585
wip/S6YKeDKX8eE.sv.srt Normal file

File diff suppressed because it is too large Load Diff

1645
wip/WP9PA8iefHE.sv.srt Normal file

File diff suppressed because it is too large Load Diff

80
wip/download-subs.py Normal file
View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
import subprocess
import sys
import os
import glob
def get_clipboard():
result = subprocess.run(['pbpaste'], capture_output=True, text=True)
return result.stdout.strip()
def set_clipboard(text):
subprocess.run(['pbcopy'], input=text, text=True)
def get_url_from_dialog():
applescript = '''
display dialog "Enter YouTube URL:" default answer "" with title "Download Swedish Subtitles" buttons {"Cancel", "OK"} default button "OK"
set userInput to text returned of result
return userInput
'''
try:
result = subprocess.run(['osascript', '-e', applescript], capture_output=True, text=True, check=True)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def clean_subtitles(srt_content):
lines = srt_content.strip().split('\n')
text_lines = []
seen_lines = set()
for line in lines:
line = line.strip()
if not line or line.isdigit() or '-->' in line:
continue
if line in seen_lines:
continue
seen_lines.add(line)
text_lines.append(line)
return ' '.join(text_lines)
def download_subtitles(url):
cmd = [
'yt-dlp',
'--write-auto-sub',
'--sub-lang', 'sv',
'--skip-download',
'--convert-subs', 'srt',
'-o', '%(id)s.%(ext)s',
url
]
subprocess.run(cmd, capture_output=True, text=True, check=True)
srt_files = glob.glob('*.sv.srt')
if srt_files:
subtitle_file = srt_files[0]
with open(subtitle_file, 'r', encoding='utf-8') as f:
raw_content = f.read()
cleaned_content = clean_subtitles(raw_content)
os.remove(subtitle_file)
return cleaned_content
sys.exit(1)
if __name__ == '__main__':
url = get_clipboard()
if not url or 'youtube.com' not in url and 'youtu.be' not in url:
url = get_url_from_dialog()
if not url:
sys.exit(1)
subtitles = download_subtitles(url)
set_clipboard(subtitles)

3495
wip/jsRe6Yg40Ho.sv.srt Normal file

File diff suppressed because it is too large Load Diff