1
0

vault backup: 2025-12-23 16:41:40
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m47s

This commit is contained in:
2025-12-23 16:41:40 +01:00
parent 3b2751808e
commit 8c0aaa9620
45 changed files with 1423 additions and 895 deletions

View File

@@ -11,6 +11,7 @@ The rest of this document describes coding conventions to follow when writing co
- Do not create a summary .md file unless asked.
- Do not create a README.md file unless asked.
- When installing a new dependency, prefer to use the latest version.
- Try to reduce indentation, prefer to use early returns or continue everwhere, be excessive about it.
## Python
- Use uv for handling python installations
@@ -19,8 +20,11 @@ The rest of this document describes coding conventions to follow when writing co
- 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.
- Use type hints for all functions and methods.
- Use global constants for compiled regex patterns, capitalized and ending with `_RE`.
- Document regex patterns with inline comments using multiple strings inside `re.compile()`. This overrides the general rule against docstrings/comments.
## Python unit testing:
- we should have a tests package under each implementation that has a test and the corresponding test foo/bar.py is tested in foo/bar/tests/test_bar.py
- 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.
@@ -42,3 +46,11 @@ def test_parametrized(subtests: pytest.Subtests) -> None:
- Prefer function based views over class based views
- Use django-stubs for type hints
- Use pytest-django for testing django applications
- Avoid comments in settings.
## Style Learnings
- **Stateful Parsing**: For complex tasks like parsing, prefer stateful classes that share context via `self` to avoid passing many arguments.
- **Match Statement Inlining**: Prefer inlining simple, single-statement logic directly into `match` cases.
- **Semantic Naming**: Use names that reflect the domain (e.g. `answer`) rather than implementation details (e.g. `spoiler`).
- **Data Propagation**: Ensure all calculated metrics (like question counts) are consistently propagated to the final output structure.
- **Factory Helpers**: Use centralized factory methods (e.g. `_create_question`) to instantiate objects with many shared common fields.- **Test Organization**: Place tests in a `tests` package within each implementation directory. Test files should be named `test_xxx.py` corresponding to the `xxx.py` file they test.

View File

@@ -6,22 +6,8 @@
{
"id": "8dd584e60438200b",
"type": "tabs",
"dimension": 58.22050290135397,
"children": [
{
"id": "baa45c5e57825965",
"type": "leaf",
"state": {
"type": "markdown",
"state": {
"file": "Anatomi & Histologi 2/2 Öra anatomi/Video 1.md",
"mode": "source",
"source": false,
"backlinks": false
},
"icon": "lucide-file",
"title": "Video 1"
}
},
{
"id": "b6de1b6650c09ff3",
"type": "leaf",
@@ -37,23 +23,25 @@
"title": "Statistik"
}
}
],
"currentTab": 1
]
},
{
"id": "1ce7592fbb71e315",
"type": "tabs",
"dimension": 41.77949709864603,
"children": [
{
"id": "2a734bede79968e0",
"id": "063c080eabc776aa",
"type": "leaf",
"pinned": true,
"state": {
"type": "pdf",
"state": {
"file": "Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf"
"file": "Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0088-KOM.pdf"
},
"pinned": true,
"icon": "lucide-file-text",
"title": "!2023-05-31-0100-DKS"
"title": "!2024-01-10-0088-KOM"
}
}
]
@@ -103,7 +91,7 @@
}
],
"direction": "horizontal",
"width": 200
"width": 264.50390243530273
},
"right": {
"id": "0948c66181b40af9",
@@ -227,42 +215,42 @@
},
"active": "b6de1b6650c09ff3",
"lastOpenFiles": [
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/30.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/29.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/28.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/27.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/26.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/25.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/24.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/23.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/22.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/21.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/20.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/19.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/18.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/17.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/16.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/15.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/14.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/13.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/12.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/11.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/10.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/9.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/30.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0088-KOM.pdf",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/29.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/28.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/27.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/26.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/25.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/24.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/23.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/22.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/21.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/20.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/19.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/18.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/17.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/16.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/15.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/14.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/13.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/12.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/11.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/10.md",
"attachments/image-48.png",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/9.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/8.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/7.md",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/6.md",
"Biokemi/Plasmidlabb/Articles/Report guidelines 2025.pdf",
"Biokemi/Plasmidlabb/Protokoll.pdf",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf",
"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/1 Öga anatomi/Slides.pdf.pdf",
"Anatomi & Histologi 2/2 Öra anatomi/Organa sensum.pdf",
"Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0009-RYY.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/2 Öra anatomi/Slides.pdf.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",
"attachments/image-120.png",
"attachments/image-119.png",
@@ -272,7 +260,6 @@
"attachments/image-115.png",
"attachments/image-114.png",
"attachments/image-113.png",
"attachments/image-112.png",
"Untitled.canvas",
"Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas",
"Biokemi/Metabolism/📋 Metabolismen översikt.canvas",

View File

@@ -5,15 +5,18 @@ date: 2024-01-10
Dra funktion till rätt lob: (1p)
Motorik
Lobus frontalis
Somatosensorik
Syn
Lobus occipitalis
Hörsel
Lobus temporalis
Lobus frontalis
Lobus occipitalis
Lobus parietalis
Lobus temporalis
```spoiler-block:
TODO
Motorik: Lobus frontalis
Somatosensorik: Lobus parietalis
Syn: Lobus occipitalis
Hörsel: Lobus temporalis
```

View File

@@ -8,8 +8,8 @@ tags:
date: 2024-01-10
---
![[image-49.png]]
Vilken siffra motsvarar medulla oblongata?
Vilken siffra motsvarar medulla oblongata? (1..13)
```spoiler-block:
TODO
11
```

View File

@@ -8,8 +8,9 @@ tags:
date: 2024-01-10
---
![[image-50.png]]
Vilken siffra pekar på thalamus? (1p)
Vilken siffra pekar på thalamus? (1..21)
(1p)
```spoiler-block:
TODO
19
```

View File

@@ -12,5 +12,5 @@ date: 2024-01-10
Markera var du återfinner barkområde för synintryck: (1p)
```spoiler-block:
TODO![[image-53.png]]
![[image-53.png]]
```

View File

@@ -8,9 +8,9 @@ tags:
date: 2024-01-10
---
![[image-54.png|330x140]]
Vilken bokstav markerar radix anterius (ventralroten)?
Vilken bokstav markerar radix anterius (ventralroten)? (A..E)
A (1p)
```spoiler-block:
TODO
A
```

View File

@@ -12,5 +12,5 @@ date: 2024-01-10
Bilden visar cortex i cerebrum (råtta). Rutan ligger i lamina .... (fyll i resten) (1p)
```spoiler-block:
TODO
granulis interna
```

View File

@@ -30,5 +30,6 @@ b) Vilken av följande celltyper har också sin cellkropp i samma område (men
_0,5p per rätt svar, inga avdrag för fel - totalt 1p_
```spoiler-block:
TODO
a) astrocyter
b) oligodendrocyter
```

View File

@@ -5,8 +5,11 @@ date: 2024-01-10
Vilket alternativ stämmer bäst på cortex cerebelli? (1p)
**Välj ett alternativ:**
- A: korncellernas axoner leder information ut ur cerebellum stjärnceller har sin cellkropp i l. granularis purkinjeceller skickar signaler från cortex
- A: korncellernas axoner leder information ut ur cerebellum
- B: stjärnceller har sin cellkropp i l. granularis
- C: purkinjeceller skickar signaler från cortex
- D: purkinjecellerna har sina dendriter i l. granularis
```spoiler-block:
TODO
C
```

View File

@@ -8,7 +8,8 @@ I vilken av följande lober återfinns Hippocampus och uncus? (1p)
- A: Lobus occipitalis
- B: Lobus temporalis
- C: Lobus frontalis
- D: Lobus parietalis
-
```spoiler-block:
TODO
B
```

View File

@@ -10,5 +10,5 @@ I vilken del av [retinae](https://en.wikipedia.org/wiki/Retina) återfinner du v
- C: Discus opticus/papilla
```spoiler-block:
TODO
C
```

View File

@@ -10,5 +10,5 @@ Vad är rätt om retinas bipolära neuron? (1p)
- C: De bipolära neuronens axoner löper genom synnerven
```spoiler-block:
TODO
A
```

View File

@@ -3,10 +3,23 @@ tags: [ah2, provfråga, frågetyp/dnd-text, öga, histologi]
date: 2024-01-10
---
Linsen består av olika komponenter. Välj tre av nedan komponenter och ordna i rätt ordning från anteriort (närmast iris) till mitten av linsen: (1p)
 Hjälp kollagenrikt lager endotel enkelt kubiskt epitel nervändslut
- endotel
- enkelt kubiskt
- enkelt skivepitel
- epitel nervändslut
- fibroblaster
- kollagenrikt lager
- Crystalliner
Anteriort:
Crystalliner enkelt skivepitel fibroblaster
a) längst fram
b) mitten
c) längst bak
```spoiler-block:
TODO
a) kollagenrikt lager
b) enkelt kubiskt epitel
c) Crystalliner
```

View File

@@ -1,12 +1,15 @@
---
tags: [ah2, provfråga, frågetyp/mcq, öga, histologi]
tags: [ah2, provfråga, frågetyp/scq, öga, histologi]
date: 2024-01-10
---
Vad är rätt om iris? (1p)
**Välj ett alternativ:**
- A: fäster till linsen med hjälp av zonulatrådar är den anteriora (främre) förlängningen av ciliarkroppen bekläds av flerskiktat oförhornat epitel på framsidan
- A: fäster till linsen med hjälp av zonulatrådar
- B: är den anteriora (främre) förlängningen av ciliarkroppen
- C: bekläds av flerskiktat oförhornat epitel på framsidan
- D: bekläds av pigmenterat epitel på framsidan
```spoiler-block:
TODO
B
```

View File

@@ -16,7 +16,8 @@ Vilken kranialnerv förmedlar hörsel? (1p)
- I: N X
- J: N XI
- K: N I
- L: VIII
```spoiler-block:
TODO
L
```

View File

@@ -12,11 +12,13 @@ date: 2024-01-10
![[image-61.png]]
Vilken bokstav pekar på
a) membranet som skiljer scala media från scala vestibularis ?
a) membranet som skiljer scala media från scala vestibularis ? (A..I)
b) sensoriska celler som kontaktar tektorialmembranet? (A..I)
b) sensoriska celler som kontaktar tektorialmembranet?
(båda ska vara rätt för att få poäng) (1p)
```spoiler-block:
TODO
a) B
b) E
```

View File

@@ -11,5 +11,5 @@ Vilka påståenden stämmer på innerörats hårceller? (1p)
- D: Håcellerna aktiveras när de apikala utskotten böjs, vilket leder till inflöde av K-joner i cellen
```spoiler-block:
TODO
D
```

View File

@@ -11,5 +11,5 @@ Vilket påstående beskriver bäst storhjärnans vita substans? (1p)
- D: I cerebrum återfinns fasciculi, dels inom en hjärnhalva, dels mellan hjärnhalvorna
```spoiler-block:
TODO
C
```

View File

@@ -2,12 +2,20 @@
tags: [ah2, provfråga, frågetyp/dnd-text, öra, histologi]
date: 2024-01-10
---
Vilken epiteltyp bekläder de delar av örat som listas nedan? (Dra rätt epitel till rätt del): (1p) cylinderepitel med mikrovilli Flerskiktad förhornad epitel
Trumhinna utsida flerskiktad oförhornad skivepitel
Mellanörat
Enkelt kubiskt epitel
Tuba auditiva flerradigt cilierad epitel
Vilken epiteltyp bekläder de delar av örat som listas nedan? (Dra rätt epitel till rätt del): (1p)
- cylinderepitel med mikrovilli
- enkelt kubiskt epitel
- flerradigt cilierad epitel
- flerskiktad förhornad epitel
- flerskiktad oförhornad skivepitel
a) Trumhinna utsida
b) Mellanörat
c) Tuba auditiva
```spoiler-block:
TODO
a) flerskiktad förhornad epitel
b) enkelt kubiskt epitel
c) flerradigt cilierad epitel
```

View File

@@ -10,10 +10,14 @@ Metathalamus
Epithalamus
ANS och Endokrin styrning
Melatoninproduktion
Omkoppling för cerebral afferens
Omkoppling synbanor
Melatoninproduktion
```spoiler-block:
TODO
Hypothalamus: ANS och Endokrin styrning
Thalamus: Omkoppling för cerebral afferens
Metathalamus: Omkoppling synbanor
Epithalamus: Melatoninproduktion
```

View File

@@ -8,7 +8,8 @@ Vilket påstående beskriver bäst cerebellum? (1p)
- A: Cerebellum är kraftigt veckad i sprickor och blad (foliae et fissurae) med vit substans där innanför samt cerebellära kärnor på djupet.
- B: Cerebellum styr kroppens balans och koordination via egna motoriska/efferenta banor
- C: Cerebellum består av två halvor, vilka binds samman av corpus callosum
- D: Cerebellum är veckad som storhjärnan, men är både mindre och innehåller klart färre neuron.
```spoiler-block:
TODO
A
```

View File

@@ -11,5 +11,5 @@ Vilken struktur binder samman Cerebellum med övriga CNS? (1p)
- D: Basis pontis
```spoiler-block:
TODO
A
```

View File

@@ -11,5 +11,5 @@ Vilken av följande strukturer finner du i hjärnstammens övre del? (1p)
- D: Pyramis
```spoiler-block:
TODO
C
```

View File

@@ -11,5 +11,5 @@ Vilket påstående beskriver bäst ryggmärgens anatomi? (1p)
- D: Nedre delen av ryggmärgen benämns conus och från dem hänger det ned lumbosakrala spinalnerver som en
```spoiler-block:
TODO
D
```

View File

@@ -11,5 +11,5 @@ Vilken kranialnerv förmedlar ansiktes sensorik? (1p)
- D: N Facialis
```spoiler-block:
TODO
A
```

View File

@@ -6,7 +6,7 @@
| 2022-06-01 | ✅ | ✅ | ✅ | | ✅ | ✅ | |
| 2023-01-11 | ✅ | ✅ | ✅ | | ✅ | ✅ | |
| 2023-05-31 | ✅ | ✅ | ✅ | | ✅ | ✅ | |
| 2024-01-10 | ✅ | ✅ | ✅ | | ✅ | | |
| 2024-01-10 | ✅ | ✅ | ✅ | | ✅ | | |
| 2024-05-29 | ✅ | ✅ | ✅ | | ✅ | | |
| 2025-01-15 | ✅ | ✅ | ✅ | | ✅ | | |
| 2025-02-08 | ✅ | ✅ | ✅ | | ✅ | | |

BIN
quiz/.coverage Normal file

Binary file not shown.

View File

@@ -21,6 +21,9 @@ def django_db_setup():
'NAME': None,
},
}
settings.PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
@pytest.fixture

Binary file not shown.

View File

@@ -5,6 +5,8 @@ description = "Medical quiz application with auto-import from Obsidian"
requires-python = ">=3.13"
dependencies = [
"django>=6.0.0",
"mistune>=3.1.4",
"python-frontmatter>=1.1.0",
"watchdog>=6.0.0",
]
@@ -13,5 +15,11 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
dev-dependencies = []
dev-dependencies = [
"pytest>=9.0.0",
"pytest-cov>=7.0.0",
"pytest-django>=4.11.1",
"pytest-mock>=3.15.1",
"pytest-subtests>=0.15.0",
]

View File

@@ -8,7 +8,7 @@ addopts =
--strict-markers
--tb=short
--reuse-db
testpaths = tests
testpaths = .
markers =
admin: Admin interface tests
import: Import and parsing tests

View File

@@ -17,7 +17,7 @@ class TestAdminPages:
email='admin@test.com',
password='admin123'
)
client.login(username='testadmin', password='admin123')
client.force_login(admin_user)
return client
@pytest.fixture

View File

@@ -1,128 +0,0 @@
from django.test import TestCase
from pathlib import Path
from quiz.utils.importer import parse_matching_question
class MatchingQuestionParserTests(TestCase):
"""Tests for matching question parser"""
def test_parse_matching_5x5(self):
"""Test parsing the real 5x5 matching question"""
content = """---
tags: [ah2, provfråga, frågetyp/matching, anatomi, öra]
date: 2023-05-31
---
**Matcha rätt funktion med rätt lob:**
(1p för alla rätt, inga delpoäng)
- Smak
- Syn
- Somatosensorik
- Motorik
- Hörsel
- Lobus frontalis
- Lobus Insularis
- Lobus temporalis
- Lobus parietalis
- Lobus occipitalis
```spoiler-block:
Smak: Lobus Insularis
Syn: Lobus occipitalis
Somatosensorik: Lobus parietalis
Motorik: Lobus frontalis
Hörsel: Lobus temporalis
```
"""
is_matching, data = parse_matching_question(content)
self.assertTrue(is_matching)
self.assertEqual(data['question_type'], 'matching')
self.assertTrue(data['has_answer'])
self.assertEqual(len(data['left_items']), 5)
self.assertEqual(len(data['top_items']), 5)
self.assertEqual(len(data['correct_pairs']), 5)
# Check specific items
self.assertIn('Smak', data['left_items'])
self.assertIn('Lobus frontalis', data['top_items'])
# Check correct pairs (Smak -> Lobus Insularis)
# Smak is index 0, Lobus Insularis is index 1
self.assertIn([0, 1], data['correct_pairs'])
def test_parse_matching_4x4(self):
"""Test parsing a 4x4 matching question"""
content = """---
tags: [test, frågetyp/matching]
date: 2023-01-01
---
Match the items:
- Item 1
- Item 2
- Item 3
- Item 4
- Match A
- Match B
- Match C
- Match D
```spoiler-block:
Item 1: Match B
Item 2: Match A
Item 3: Match D
Item 4: Match C
```
"""
is_matching, data = parse_matching_question(content)
self.assertTrue(is_matching)
self.assertEqual(len(data['left_items']), 4)
self.assertEqual(len(data['top_items']), 4)
self.assertEqual(len(data['correct_pairs']), 4)
def test_parse_matching_todo(self):
"""Test parsing matching question with TODO answer"""
content = """---
tags: [test, frågetyp/matching]
date: 2023-01-01
---
Match the items:
- Item 1
- Item 2
- Match A
- Match B
```spoiler-block:
TODO
```
"""
is_matching, data = parse_matching_question(content)
self.assertTrue(is_matching)
self.assertFalse(data['has_answer'])
self.assertEqual(len(data['correct_pairs']), 0)
def test_parse_matching_no_answer(self):
"""Test parsing matching question without answer block"""
content = """---
tags: [test, frågetyp/matching]
date: 2023-01-01
---
Match the items:
- Item 1
- Item 2
- Match A
- Match B
"""
is_matching, data = parse_matching_question(content)
self.assertTrue(is_matching)
self.assertFalse(data['has_answer'])
self.assertEqual(len(data['correct_pairs']), 0)

View File

@@ -0,0 +1,85 @@
import pytest
from django.urls import reverse
from quiz.models import Course, Tag, Question, Exam, QuizUser, QuizSession
from quiz.forms import CreateQuizForm
@pytest.mark.django_db
class TestQuizCreation:
@pytest.fixture(autouse=True)
def setup_data(self, client):
# Clear database to ensure fresh state
Question.objects.all().delete()
Tag.objects.all().delete()
QuizSession.objects.all().delete()
Course.objects.all().delete()
self.client = client
s = self.client.session
s.save()
self.user = QuizUser.objects.create(session_key=s.session_key)
self.course1 = Course.objects.create(name="Course 1", code="C1")
self.course2 = Course.objects.create(name="Course 2", code="C2")
self.tag1 = Tag.objects.create(name="Tag1", slug="tag-1")
self.exam1 = Exam.objects.create(course=self.course1, date="2023-01-01")
self.exam2 = Exam.objects.create(course=self.course2, date="2023-01-02")
self.q1 = Question.objects.create(
exam=self.exam1, text="UNIQUE_Q1_TEXT", correct_answer="A", file_path="path1"
)
self.q1.tags.add(self.tag1)
self.q2 = Question.objects.create(
exam=self.exam2, text="UNIQUE_Q2_TEXT", correct_answer="A,B", file_path="path2"
)
def test_create_quiz_form_valid(self):
form_data = {
'course': self.course1.id,
'tags': [self.tag1.id],
'question_type': ['single']
}
form = CreateQuizForm(data=form_data)
assert form.is_valid()
def test_create_quiz_view_post(self):
response = self.client.post(reverse('create_quiz'), {
'course': self.course1.id,
'tags': [self.tag1.id],
'question_type': ['single']
})
session = QuizSession.objects.get(user=self.user)
assert response.status_code == 302
assert response.url == reverse('quiz_mode', args=[session.id])
assert session.course.id == self.course1.id
assert list(session.tags.values_list('id', flat=True)) == [self.tag1.id]
assert session.question_types == ['single']
def test_get_next_question_filters(self):
session = QuizSession.objects.create(user=self.user, course=self.course1)
response = self.client.get(reverse('next_question', args=[session.id]))
assert response.status_code == 200
assert "UNIQUE_Q1_TEXT" in response.content.decode()
# Now change filter to Course 2
session.course = self.course2
session.save()
response = self.client.get(reverse('next_question', args=[session.id]))
assert "UNIQUE_Q2_TEXT" in response.content.decode()
def test_filter_by_type(self):
session = QuizSession.objects.create(user=self.user, question_types=['multi'])
response = self.client.get(reverse('next_question', args=[session.id]))
assert "UNIQUE_Q2_TEXT" in response.content.decode()
session.question_types = ['single']
session.save()
response = self.client.get(reverse('next_question', args=[session.id]))
assert "UNIQUE_Q1_TEXT" in response.content.decode()

View File

@@ -10,8 +10,13 @@ class QuizViewsTestCase(TestCase):
"""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")
# Ensure session exists and get its key
s = self.client.session
s.save()
session_key = s.session_key
# Create test user with the same session key
self.user = QuizUser.objects.create(session_key=session_key)
# Create test course
self.course = Course.objects.create(name="Test Course")
@@ -24,14 +29,15 @@ class QuizViewsTestCase(TestCase):
)
# Create test tags
self.tag1 = Tag.objects.create(name="Tag 1")
self.tag2 = Tag.objects.create(name="Tag 2")
self.tag1 = Tag.objects.create(name="Tag 1", slug="tag-1")
self.tag2 = Tag.objects.create(name="Tag 2", slug="tag-2")
# Create test questions
self.question1 = Question.objects.create(
text="Test question 1?",
correct_answer="A",
exam=self.exam
exam=self.exam,
file_path="test1.md"
)
self.question1.tags.add(self.tag1)
@@ -41,7 +47,8 @@ class QuizViewsTestCase(TestCase):
self.question2 = Question.objects.create(
text="Test question 2 (multi)?",
correct_answer="A,B",
exam=self.exam
exam=self.exam,
file_path="test2.md"
)
self.question2.tags.add(self.tag1, self.tag2)
@@ -58,7 +65,7 @@ class QuizViewsTestCase(TestCase):
"""Test dashboard index view"""
response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Välkommen')
self.assertContains(response, 'Snabbstart')
self.assertIn('active_sessions', response.context)
self.assertIn('form', response.context)

View File

View File

@@ -0,0 +1,261 @@
import pytest
import time
from pathlib import Path
from quiz.utils.importer import parse_markdown_question, import_question_file, ImportStats
from quiz.models import Question, Option
@pytest.mark.django_db
@pytest.mark.import_tests
class TestMarkdownParsing:
"""Test parsing of various Obsidian markdown question formats"""
def test_parse_single_choice_question(self):
"""Test parsing standard single choice question (SCQ)"""
content = """---
tags: [ah2, provfråga, frågetyp/scq, anatomi]
date: 2022-01-15
---
What is the correct answer?
**Välj ett alternativ:**
- A: Wrong answer
- B: Correct answer
- C: Another wrong
```spoiler-block:
B
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data['text'] == 'What is the correct answer?'
assert data['correct_answer'] == 'B'
assert data['has_answer'] is True
assert data['question_type'] == 'scq'
assert len(data['options']) == 3
assert data['options'][0] == ('A', 'Wrong answer')
assert data['options'][1] == ('B', 'Correct answer')
def test_parse_multiple_choice_question(self):
"""Test parsing multiple choice question (MCQ) with 'och' separator"""
content = """---
tags: [ah2, provfråga, frågetyp/mcq, cerebrum]
date: 2022-01-15
---
Vilka av följande räknas till storhjärnans basala kärnor?
**Välj två alternativ**
- A: Putamen
- B: Nucleus Ruber
- C: Substantia nigra
- D: Nucleus caudatus
```spoiler-block:
A och D
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert 'Vilka av följande' in data['text']
assert data['correct_answer'] == 'A,D' # Normalized to comma-separated
assert data['has_answer'] is True
assert data['question_type'] == 'mcq'
assert len(data['options']) == 4
def test_parse_multiple_choice_comma_separated(self):
"""Test MCQ with comma-separated answer"""
content = """---
tags: [frågetyp/mcq]
---
Select two options:
- A: Option A
- B: Option B
- C: Option C
- D: Option D
```spoiler-block:
B, C
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert data['correct_answer'] == 'B,C'
assert data['has_answer'] is True
def test_parse_matching_question(self):
"""Test parsing matching question (DND/Matching)"""
content = """---
tags: [ah2, provfråga, frågetyp/matching, anatomi, öra]
date: 2023-05-31
---
**Matcha rätt funktion med rätt lob:**
(1p för alla rätt, inga delpoäng)
- Smak
- Syn
- Somatosensorik
- Motorik
- Hörsel
**Alternativ:**
- Lobus frontalis
- Lobus Insularis
- Lobus temporalis
- Lobus parietalis
- Lobus occipitalis
```spoiler-block:
Smak: Lobus Insularis
Syn: Lobus occipitalis
Somatosensorik: Lobus parietalis
Motorik: Lobus frontalis
Hörsel: Lobus temporalis
```
"""
is_matching, data = parse_markdown_question(Path("test.md"), content)
assert is_matching is True
assert data['question_type'] == 'matching'
assert data['has_answer'] is True
assert len(data['left_items']) == 5
assert len(data['top_items']) == 5
assert len(data['correct_pairs']) == 5
def test_parse_textalternativ_question(self):
"""Test text alternative question type"""
content = """---
tags: [frågetyp/textalternativ, öga, anatomi]
---
Svara på följande frågor:
a) Bokstaven B sitter i en lob, vilken?
- Lobus temporalis
- Lobus frontalis
- Lobus parietalis
b) Vilket funktionellt centra återfinns där?
- Syncentrum
- Motorcentrum
- Somatosensoriskt centrum
```spoiler-block:
a) Lobus parietalis
b) Somatosensoriskt centrum
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data['question_type'] == 'textalternativ'
assert data['has_answer'] is True
assert 'Lobus parietalis' in data['correct_answer']
assert 'Somatosensoriskt centrum' in data['correct_answer']
def test_parse_textfalt_question(self):
"""Test text field (fill-in) question type"""
content = """---
tags: [frågetyp/textfält, öga]
---
**Fyll i rätt siffra!**
a) Vilken siffra pekar på gula fläcken?
b) Vilken siffra pekar på choroidea?
```spoiler-block:
a) 7
b) 6
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data['question_type'] == 'textfält'
assert data['has_answer'] is True
assert '7' in data['correct_answer']
assert '6' in data['correct_answer']
@pytest.mark.django_db
@pytest.mark.import_tests
class TestQuestionImport:
"""Test actual import of questions to database"""
def test_import_single_question(self, tmp_path):
"""Test importing a single question file"""
question_file = tmp_path / "question1.md"
question_file.write_text("""---
tags: [frågetyp/scq]
---
Test question?
- A: Correct
- B: Wrong
```spoiler-block:
A
```
""")
stats = ImportStats()
result = import_question_file(question_file, tmp_path, stats, force=True)
assert result in ['imported', 'updated']
assert stats.questions_with_answers == 1
# Verify in database
question = Question.objects.get(text='Test question?')
assert question.correct_answer == 'A'
assert question.options.count() == 2
def test_mtime_tracking(self, tmp_path):
"""Test that file modification time is tracked"""
question_file = tmp_path / "question4.md"
question_file.write_text("""---
tags: [frågetyp/scq]
---
What is the correct answer?
```spoiler-block:
A
```
""")
stats = ImportStats()
import_question_file(question_file, tmp_path, stats, force=True)
question = Question.objects.get(text='What is the correct answer?')
assert question.file_mtime == question_file.stat().st_mtime
def test_update_existing_question(self, tmp_path):
"""Test updating an existing question"""
question_file = tmp_path / "question5.md"
# Initial import
question_file.write_text("""---
tags: [frågetyp/scq]
---
Question to update?
```spoiler-block:
A
```
""")
import_question_file(question_file, tmp_path, ImportStats(), force=True)
# Update the file
time.sleep(0.1)
question_file.write_text("""---
tags: [frågetyp/scq]
---
Question to update?
```spoiler-block:
B
```
""")
stats = ImportStats()
result = import_question_file(question_file, tmp_path, stats, force=False)
assert result == 'updated'
assert Question.objects.get(text='Question to update?').correct_answer == 'B'

View File

@@ -0,0 +1,187 @@
import datetime
from quiz.utils.unified_parser import UnifiedParser, QuestionType
def test_parse_mcq_question():
content = """---
tags: [frågetyp/mcq, ah2]
date: 2024-03-21
---
Question?
- A: Yes
- B: No
- C: Maybe
- D: Never
```spoiler-block:
A och D
```"""
data = UnifiedParser(content).parse()
assert data.type == QuestionType.MCQ
assert data.question == "Question?"
assert data.answer == ["A", "D"]
assert data.num_questions == 1
assert data.is_complete is True
assert data.options == ["A: Yes", "B: No", "C: Maybe", "D: Never"]
assert data.metadata == {"tags": ["frågetyp/mcq", "ah2"], "date": datetime.date(2024, 3, 21)}
assert not data.sub_questions
def test_parse_scq_question():
content = """---
tags: [frågetyp/scq]
---
Pick one:
- A: One
- B: Two
```spoiler-block:
B
```"""
data = UnifiedParser(content).parse()
assert data.type == QuestionType.SCQ
assert data.question == "Pick one:"
assert data.answer == "B"
assert data.num_questions == 1
assert data.is_complete is True
assert data.options == ["A: One", "B: Two"]
assert not data.sub_questions
def test_parse_textfält_question():
content = """---
tags: [frågetyp/textfält]
---
Name these:
a) Part 1
b) Part 2
```spoiler-block:
a) Left
b) Right
```"""
data = UnifiedParser(content).parse()
assert data.type == QuestionType.TEXTFÄLT
assert data.question == "Name these:"
assert data.answer == ["a) Left", "b) Right"]
assert data.num_questions == 2
assert len(data.sub_questions) == 2
assert data.sub_questions[0].id == "a"
assert data.sub_questions[0].text == "Part 1"
assert data.sub_questions[0].answer == "a) Left"
assert data.sub_questions[0].options is None
def test_parse_matching_question():
content = """---
tags: [frågetyp/matching]
---
Match:
- 1
- 2
- A
- B
```spoiler-block:
1: A
2: B
```"""
data = UnifiedParser(content).parse()
assert data.type == QuestionType.MATCHING
assert data.question == "Match:"
assert data.answer == [["1", "A"], ["2", "B"]]
assert data.num_questions == 1
assert data.options == ["1", "2", "A", "B"]
assert not data.sub_questions
def test_parse_question_with_image_and_instruction():
content = """---
tags: [frågetyp/scq]
---
**Välj ett alternativ:**
![[brain.png|300]]
What is this?
- A: Brain
- B: Heart
```spoiler-block:
A
```"""
data = UnifiedParser(content).parse()
assert data.type == QuestionType.SCQ
assert data.question == "What is this?"
assert data.instruction == "Välj ett alternativ:"
assert data.image == "![[brain.png]]"
assert data.is_complete is True
def test_parse_field_question_with_ranges():
content = """---
tags: [frågetyp/sifferfält]
---
Identify the structures:
a) Arachnoidea? (1..10)
(0.5 p)
b) Cortex cerebri (1..10)
(0.5 p)
```spoiler-block:
a) 7
b) 3
```"""
data = UnifiedParser(content).parse()
assert data.type == QuestionType.SIFFERFÄLT
assert data.num_questions == 2
assert len(data.sub_questions) == 2
# Part A
assert data.sub_questions[0].id == "a"
assert data.sub_questions[0].text == "Arachnoidea?"
assert data.sub_questions[0].options == [str(x) for x in range(1, 11)]
assert data.sub_questions[0].answer == "a) 7"
# Part B
assert data.sub_questions[1].id == "b"
assert data.sub_questions[1].text == "Cortex cerebri"
assert data.sub_questions[1].options == [str(x) for x in range(1, 11)]
assert data.sub_questions[1].answer == "b) 3"
def test_parse_field_question_with_list_options():
content = """---
tags: [frågetyp/sifferfält]
---
a) First (A, B, C)
b) Second (1, 2, 3)
```spoiler-block:
a) A
b) 2
```"""
data = UnifiedParser(content).parse()
assert data.sub_questions[0].options == ["A", "B", "C"]
assert data.sub_questions[1].options == ["1", "2", "3"]
def test_parse_hotspot_question():
content = """---
tags: [frågetyp/hotspot]
---
Klicka på hippocampus!
```spoiler-block:
![[brain_atlas.png]]
Det här är hippocampus.
```"""
data = UnifiedParser(content).parse()
assert data.type == QuestionType.HOTSPOT
assert data.answer == "Det här är hippocampus."
assert data.answer_image == "![[brain_atlas.png]]"
assert data.is_complete is True
def test_completeness_missing_sub_questions():
content = """---
tags: [frågetyp/textfält]
---
a) one
b) two
```spoiler-block:
a) found
```"""
data = UnifiedParser(content).parse()
assert data.num_questions == 2
assert data.is_complete is False
assert len(data.sub_questions) == 2
assert data.sub_questions[0].answer == "a) found"
assert data.sub_questions[1].answer is None

View File

@@ -0,0 +1,465 @@
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Any
from quiz.utils.question_parser import Node, parse_question_from_content
# === REGEX PATTERNS ===
# Matches Obsidian-style embeds like ![[image.png]] or ![[image.png|300]]
EMBED_RE = re.compile(
r"!\[\[" # Start of embed
r".*?" # Content (filename and optional pipes)
r"\]\]" # End of embed
)
# Captures the filename from an Obsidian embed, ignoring dimensions
IMAGE_RE = re.compile(
r"!\[\[" # Start of embed
r"([^|\]]+)" # Group 1: Filename (everything before | or ])
r"(?:\|.*?)?" # Optional dimension part starting with |
r"\]\]" # End of embed
)
# Matches lettered options at the start of a line, e.g., "A: Text" or "B. Text"
OPTION_LETTER_RE = re.compile(
r"^([A-Z])" # Group 1: Single uppercase letter at start
r"[:\.]?" # Optional colon or period
r"\s*" # Optional whitespace
r"(.*)$" # Group 2: The rest of the text
)
# Matches standalone uppercase letters used for answers, e.g., "A", "A och B"
ANSWER_LETTER_RE = re.compile(
r"\b" # Word boundary
r"([A-Z])" # Group 1: Single uppercase letter
r"\b" # Word boundary
)
# Matches sub-question markers like a), b) at the start of a line
SUB_QUESTION_LETTER_RE = re.compile(
r"^\s*" # Start of line and optional whitespace
r"([a-z])" # Group 1: Single lowercase letter
r"\)" # Closing parenthesis
, re.MULTILINE)
# Matches numbered sub-question markers like 1), 2) at the start of a line
SUB_QUESTION_NUMBER_RE = re.compile(
r"^\s*" # Start of line and optional whitespace
r"(\d+)" # Group 1: One or more digits
r"\)" # Closing parenthesis
, re.MULTILINE)
# Matches select range patterns like (1..10)
SELECT_RANGE_RE = re.compile(
r"\(" # Opening parenthesis
r"(\d+)" # Group 1: Start number
r"\.\." # Range dots
r"(\d+)" # Group 2: End number
r"\)" # Closing parenthesis
)
# Matches letter range patterns like (A..H)
SELECT_LETTER_RANGE_RE = re.compile(
r"\(" # Opening parenthesis
r"([A-Z])" # Group 1: Start letter
r"\.\." # Range dots
r"([A-Z])" # Group 2: End letter
r"\)" # Closing parenthesis
)
# Matches select list patterns like (A, B, C)
SELECT_LIST_RE = re.compile(
r"\(" # Opening parenthesis
r"(" # Group 1: The list content
r"[^)]+" # Anything but closing parenthesis
r"," # At least one comma
r"[^)]+" # Anything but closing parenthesis
r")"
r"\)" # Closing parenthesis
)
# Matches sub-question markers in mid-text (used for splitting intro text)
FIELD_MARKER_RE = re.compile(
r"\b" # Word boundary
r"([a-z]|\d+)" # Group 1: Letter or digit
r"\)" # Closing parenthesis
)
# Matches sub-question markers (a, b or 1, 2) at start of line for splitting
SUB_QUESTION_SPLIT_RE = re.compile(
r"^\s*" # Start of line and optional whitespace
r"([a-z]|\d+)" # Group 1: Single letter or one or more digits
r"\)" # Closing parenthesis
r"\s*" # Optional trailing whitespace
, re.MULTILINE)
# Matches point markers like (0.5 p) or (1 p)
POINTS_RE = re.compile(
r"\(" # Opening parenthesis
r"\d+" # One or more digits
r"(?:\.\d+)?" # Optional decimal part
r"\s*" # Optional whitespace
r"p" # Literal 'p'
r"\)" # Closing parenthesis
)
class QuestionType(Enum):
MCQ = "mcq"
SCQ = "scq"
MATCHING = "matching"
TEXTALTERNATIV = "textalternativ"
TEXTFÄLT = "textfält"
SIFFERFÄLT = "sifferfält"
HOTSPOT = "hotspot"
SAMMANSATT = "sammansatt"
DND_TEXT = "dnd-text"
DND_BILD = "dnd-bild"
SANT_FALSKT = "sant-falskt"
@dataclass
class SubQuestion:
id: str # 'a', 'b', etc.
text: str # Text for this part
answer: Any = None
options: list[str] | None = None # None if text input
@dataclass
class QuestionData:
type: QuestionType
question: str
answer: Any # str | list[str] | list[list[str]]
num_questions: int = 1 # Total sub-questions (a, b, c...)
is_complete: bool = False
options: list[str] = field(default_factory=list)
image: str | None = None
answer_image: str | None = None
instruction: str | None = None
metadata: dict = field(default_factory=dict)
sub_questions: list[SubQuestion] = field(default_factory=list)
class UnifiedParser:
def __init__(self, content: str):
self.content = content
self.parsed = parse_question_from_content(content)
self.metadata = self.parsed.metadata
self.nodes = self.parsed.nodes
# Pre-extract common fields
self.type = self._extract_type()
self.question = self._extract_question_text()
self.instruction = self._extract_instruction()
self.image = self._extract_image()
self.num_questions = self._count_sub_questions()
def parse(self) -> QuestionData:
match self.type:
case QuestionType.MCQ | QuestionType.SCQ:
data = self._parse_choice_question()
case QuestionType.MATCHING:
data = self._create_question(
answer=self._extract_answer_pairs(),
options=self._extract_bullet_list_options()
)
case QuestionType.TEXTALTERNATIV:
data = self._create_question(
answer=self._extract_raw_answer(),
options=self._extract_bullet_list_options()
)
case QuestionType.TEXTFÄLT:
data = self._parse_text_field()
case QuestionType.SIFFERFÄLT:
data = self._create_question(answer=self._extract_raw_answer())
case QuestionType.HOTSPOT:
data = self._parse_hotspot()
case QuestionType.SAMMANSATT:
data = self._create_question(answer=self._extract_answer_lines())
case QuestionType.DND_TEXT:
data = self._create_question(answer=self._extract_answer_lines())
case QuestionType.DND_BILD:
data = self._create_question(answer=self._extract_answer_lines())
case QuestionType.SANT_FALSKT:
data = self._create_question(answer=self._extract_answer_pairs())
case _:
raise ValueError(f"Unsupported question type: {self.type}")
data.num_questions = self.num_questions
data.sub_questions = self._extract_sub_questions(data)
data.is_complete = self._check_completeness(data)
return data
def _check_completeness(self, data: QuestionData) -> bool:
"""Verify if the answer is complete (no TODOs, matches sub-question count)."""
content = self._extract_raw_answer()
if not content or "TODO" in content:
return False
# If we have sub-questions, ensure we have enough answer lines/parts
if data.num_questions > 1:
if isinstance(data.answer, list):
if data.type in [QuestionType.MCQ, QuestionType.SCQ]:
return len(data.answer) > 0
return len(data.answer) >= data.num_questions
else:
return False
return True
def _count_sub_questions(self) -> int:
"""Count sub-questions like a), b), c) or 1), 2) in the question text."""
md_content = self.parsed.raw_content
# Count lettered sub-questions: a), b), c)...
letters = SUB_QUESTION_LETTER_RE.findall(md_content)
if letters:
unique_letters = sorted(list(set(letters)))
if "a" in unique_letters:
max_letter = max(unique_letters)
return ord(max_letter) - ord("a") + 1
# Count numbered sub-questions: 1), 2), 3)...
numbers = SUB_QUESTION_NUMBER_RE.findall(md_content)
if numbers:
unique_numbers = sorted(list(set(map(int, numbers))))
if 1 in unique_numbers:
return max(unique_numbers)
return 1
def _create_question(
self,
answer: Any,
options: list[str] = None,
answer_image: str | None = None
) -> QuestionData:
"""Create a QuestionData object with common fields pre-populated."""
return QuestionData(
type=self.type,
question=self.question,
answer=answer,
options=options or [],
image=self.image,
answer_image=answer_image,
instruction=self.instruction,
metadata=self.metadata
)
# === Extraction Helpers ===
def _extract_type(self) -> QuestionType:
tags = self.metadata.get("tags", [])
for tag in tags:
if tag.startswith("frågetyp/"):
type_str = tag.split("/", 1)[1]
try:
return QuestionType(type_str)
except ValueError:
continue
return QuestionType.MCQ # Default
def _extract_question_text(self) -> str:
texts = []
for node in self.nodes:
if node.type == "paragraph":
text = node.text.strip()
# Skip instructions
if text.startswith("Välj") and "alternativ" in text:
continue
# If paragraph contains a sub-question marker, stop there
# We use a more liberal search here because mistune might have joined lines
first_marker = FIELD_MARKER_RE.search(text)
if first_marker:
text = text[:first_marker.start()].strip()
if text:
# Only add if it doesn't look like an instruction we already skipped
if not (text.startswith("Välj") and "alternativ" in text):
texts.append(text)
break # Stop collecting intro text once we hit a sub-question
# Clean and collect
text = EMBED_RE.sub("", text).strip()
text = text.replace("**", "")
if text:
texts.append(text)
return "\n".join(texts)
def _extract_instruction(self) -> str | None:
for node in self.nodes:
if node.type == "paragraph":
text = node.text.strip()
if "Välj" in text and "alternativ" in text:
return text.replace("**", "")
return None
def _extract_image(self) -> str | None:
for node in self.nodes:
# Check for direct embed nodes
if node.type == "embed":
return f"![[{node.attrs['filename']}]]"
# Check inside paragraphs/lists for inline embeds
if node.type in ["paragraph", "list"]:
for child in node.children:
if child.type == "embed":
return f"![[{child.attrs['filename']}]]"
if node.raw:
match = IMAGE_RE.search(node.raw)
if match:
return f"![[{match.group(1)}]]"
return None
def _extract_sub_questions(self, data: QuestionData) -> list[SubQuestion]:
# Only split the text BEFORE the spoiler block to avoid misidentifying markers in answers
full_raw = self.parsed.raw_content
parts = full_raw.split("```", 1)
question_portion = parts[0]
# Split by sub-question markers at the start of lines: a), b) or 1), 2)
segments = SUB_QUESTION_SPLIT_RE.split(question_portion)[1:]
sub_questions = []
# segments will be [id1, text1, id2, text2, ...]
for i in range(0, len(segments), 2):
q_id = segments[i]
q_full_text = segments[i+1].strip()
# Extract options if any (for select fields)
options = self._extract_select_options(q_full_text)
# Clean text (remove point markers like (0.5 p) and select patterns)
clean_text = SELECT_RANGE_RE.sub("", q_full_text)
clean_text = SELECT_LETTER_RANGE_RE.sub("", clean_text)
clean_text = SELECT_LIST_RE.sub("", clean_text)
clean_text = POINTS_RE.sub("", clean_text).strip()
# Extract answer for this part
answer = None
if isinstance(data.answer, list) and i//2 < len(data.answer):
answer = data.answer[i//2]
elif isinstance(data.answer, str):
lines = [l.strip() for l in data.answer.split("\n") if l.strip()]
if i//2 < len(lines):
answer = lines[i//2]
elif data.num_questions == 1:
answer = data.answer
sub_questions.append(SubQuestion(
id=q_id,
text=clean_text,
answer=answer,
options=options
))
return sub_questions
def _extract_select_options(self, text: str) -> list[str] | None:
"""Extract options from patterns like (1..10), (A..D), or (A, B, C)."""
# Numerical range (1..10)
match = SELECT_RANGE_RE.search(text)
if match:
start, end = map(int, match.groups())
return [str(x) for x in range(start, end + 1)]
# Letter range (A..H)
match = SELECT_LETTER_RANGE_RE.search(text)
if match:
start, end = match.groups()
return [chr(x) for x in range(ord(start), ord(end) + 1)]
# Comma-separated list (A, B, C)
match = SELECT_LIST_RE.search(text)
if match:
items = match.group(1).split(",")
return [item.strip() for item in items]
return None
def _extract_lettered_options(self) -> list[str]:
options = []
for node in self.nodes:
if node.type == "list":
for item in node.children:
item_text = item.text.strip()
if OPTION_LETTER_RE.match(item_text):
options.append(item_text)
return options
def _extract_bullet_list_options(self) -> list[str]:
options = []
for node in self.nodes:
if node.type == "list":
for item in node.children:
options.append(item.text.strip())
return options
def _extract_raw_answer(self) -> str:
for node in self.nodes:
if node.type == "block_code" and node.attrs.get("info") == "spoiler-block:":
return node.raw.strip()
return ""
def _extract_answer_letters(self) -> list[str]:
content = self._extract_raw_answer()
if not content or content == "TODO":
return []
return ANSWER_LETTER_RE.findall(content)
def _extract_answer_lines(self) -> list[str]:
content = self._extract_raw_answer()
if not content or content == "TODO":
return []
return [line.strip() for line in content.split("\n") if line.strip()]
def _extract_answer_pairs(self) -> list[list[str]]:
lines = self._extract_answer_lines()
pairs = []
for line in lines:
if ":" in line:
key, value = line.split(":", 1)
pairs.append([key.strip(), value.strip()])
return pairs
# === Question Type Handlers ===
def _parse_choice_question(self) -> QuestionData:
answer_letters = self._extract_answer_letters()
if self.type == QuestionType.MCQ:
answer = answer_letters
else:
answer = answer_letters[0] if answer_letters else ""
return self._create_question(
answer=answer,
options=self._extract_lettered_options()
)
def _parse_text_field(self) -> QuestionData:
lines = self._extract_answer_lines()
return self._create_question(
answer=lines if len(lines) > 1 else (lines[0] if lines else "")
)
def _parse_hotspot(self) -> QuestionData:
content = self._extract_raw_answer()
answer_image = None
match = IMAGE_RE.search(content)
if match:
answer_image = f"![[{match.group(1)}]]"
answer_text = EMBED_RE.sub("", content).strip()
else:
answer_text = content
return self._create_question(
answer=answer_text,
answer_image=answer_image
)

View File

@@ -1,2 +0,0 @@
# This makes tests a package

View File

@@ -1,576 +0,0 @@
import pytest
from pathlib import Path
from quiz.utils.importer import parse_markdown_question, import_question_file, ImportStats
from quiz.models import Question, Option
@pytest.mark.django_db
@pytest.mark.import_tests
class TestMarkdownParsing:
"""Test parsing of various Obsidian markdown question formats"""
def test_parse_single_choice_question(self):
"""Test parsing standard single choice question (SCQ)"""
content = """---
tags: [ah2, provfråga, frågetyp/scq, anatomi]
date: 2022-01-15
---
What is the correct answer?
**Välj ett alternativ:**
- A: Wrong answer
- B: Correct answer
- C: Another wrong
```spoiler-block:
B
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data['text'] == 'What is the correct answer?'
assert data['correct_answer'] == 'B'
assert data['has_answer'] is True
assert data['question_type'] == 'scq'
assert len(data['options']) == 3
assert data['options'][0] == ('A', 'Wrong answer')
assert data['options'][1] == ('B', 'Correct answer')
def test_parse_multiple_choice_question(self):
"""Test parsing multiple choice question (MCQ) with 'och' separator"""
content = """---
tags: [ah2, provfråga, frågetyp/mcq, cerebrum]
date: 2022-01-15
---
Vilka av följande räknas till storhjärnans basala kärnor?
**Välj två alternativ**
- A: Putamen
- B: Nucleus Ruber
- C: Substantia nigra
- D: Nucleus caudatus
```spoiler-block:
A och D
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert 'Vilka av följande' in data['text']
assert data['correct_answer'] == 'A,D' # Normalized to comma-separated
assert data['has_answer'] is True
assert data['question_type'] == 'mcq'
assert len(data['options']) == 4
def test_parse_multiple_choice_comma_separated(self):
"""Test MCQ with comma-separated answer"""
content = """---
tags: [frågetyp/mcq]
---
Select two options:
- A: Option A
- B: Option B
- C: Option C
- D: Option D
```spoiler-block:
B, C
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert data['correct_answer'] == 'B,C'
assert data['has_answer'] is True
def test_parse_options_without_colon(self):
"""Test parsing options in format '- A' without text"""
content = """---
tags: [frågetyp/scq]
---
Which letter?
**Välj ett alternativ:**
- A
- B
- C
- D
```spoiler-block:
C
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert len(data['options']) == 4
assert all(text == '' for _, text in data['options'])
assert data['correct_answer'] == 'C'
def test_parse_textalternativ_question(self):
"""Test text alternative question type"""
content = """---
tags: [frågetyp/textalternativ, öga, anatomi]
---
Svara på följande frågor:
a) Bokstaven B sitter i en lob, vilken?
- Lobus temporalis
- Lobus frontalis
- Lobus parietalis
b) Vilket funktionellt centra återfinns där?
- Syncentrum
- Motorcentrum
- Somatosensoriskt centrum
```spoiler-block:
a) Lobus parietalis
b) Somatosensoriskt centrum
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data['question_type'] == 'textalternativ'
assert data['has_answer'] is True
assert 'Lobus parietalis' in data['correct_answer']
assert 'Somatosensoriskt centrum' in data['correct_answer']
def test_parse_textfalt_question(self):
"""Test text field (fill-in) question type"""
content = """---
tags: [frågetyp/textfält, öga]
---
**Fyll i rätt siffra!**
a) Vilken siffra pekar på gula fläcken?
b) Vilken siffra pekar på choroidea?
```spoiler-block:
a) 7
b) 6
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data['question_type'] == 'textfält'
assert data['has_answer'] is True
assert '7' in data['correct_answer']
assert '6' in data['correct_answer']
def test_skip_todo_answers(self):
"""Test that questions with TODO are skipped"""
content = """---
tags: [frågetyp/mcq]
---
What is this?
- A: Option A
- B: Option B
```spoiler-block:
TODO
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data['has_answer'] is False
def test_skip_non_question_files(self):
"""Test that files without question tags are skipped"""
content = """---
tags: [ah2, notes, general]
---
This is just a note, not a question.
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is False
def test_parse_with_images(self):
"""Test parsing questions with embedded images"""
content = """---
tags: [frågetyp/scq, bild]
---
![[image.png|338x258]]
Vilken bokstav på denna bild sitter på Mesencephalon?
**Välj ett alternativ:**
- A
- B
- C
- D
- E
- F
```spoiler-block:
F
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert 'Vilken bokstav' in data['text']
assert data['correct_answer'] == 'F'
assert len(data['options']) == 6
def test_parse_yaml_list_format_tags(self):
"""Test parsing tags in YAML list format"""
content = """---
tags:
- ah2
- provfråga
- frågetyp/scq
- anatomi
date: 2022-01-15
---
Question text?
- A: Answer A
- B: Answer B
```spoiler-block:
A
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data['question_type'] == 'scq'
def test_parse_mixed_option_formats(self):
"""Test parsing with inconsistent option formatting"""
content = """---
tags: [frågetyp/mcq]
---
Select correct options:
**Välj två alternativ:**
- A: First option with text
- B:Second option no space
- C: Third option extra spaces
- D:Fourth with trailing
```spoiler-block:
A och C
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert len(data['options']) == 4
assert data['options'][0] == ('A', 'First option with text')
assert data['options'][1] == ('B', 'Second option no space')
assert data['correct_answer'] == 'A,C'
def test_parse_question_with_multiple_paragraphs(self):
"""Test question text extraction with multiple paragraphs"""
content = """---
tags: [frågetyp/scq]
---
This is a longer question that spans multiple lines
and has additional context.
**Välj ett alternativ:**
- A: Answer
- B: Another
```spoiler-block:
A
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert 'This is a longer question' in data['text']
@pytest.mark.django_db
@pytest.mark.import_tests
class TestQuestionImport:
"""Test actual import of questions to database"""
def test_import_single_question(self, tmp_path):
"""Test importing a single question file"""
question_file = tmp_path / "question1.md"
question_file.write_text("""---
tags: [frågetyp/scq]
---
Test question?
- A: Correct
- B: Wrong
```spoiler-block:
A
```
""")
stats = ImportStats()
result = import_question_file(question_file, tmp_path, stats, force=True)
assert result in ['imported', 'updated']
assert stats.questions_with_answers == 1
assert stats.mcq_questions == 1
# Verify in database
question = Question.objects.get(text='Test question?')
assert question.correct_answer == 'A'
assert question.options.count() == 2
def test_import_multi_select_question(self, tmp_path):
"""Test importing multi-select question"""
question_file = tmp_path / "question2.md"
question_file.write_text("""---
tags: [frågetyp/mcq]
---
Multi-select question?
- A: First correct
- B: Wrong
- C: Second correct
```spoiler-block:
A och C
```
""")
stats = ImportStats()
import_question_file(question_file, tmp_path, stats, force=True)
question = Question.objects.get(text='Multi-select question?')
assert question.correct_answer == 'A,C'
assert question.options.count() == 3
def test_skip_question_without_answer(self, tmp_path):
"""Test that questions with TODO are not imported"""
question_file = tmp_path / "question3.md"
question_file.write_text("""---
tags: [frågetyp/scq]
---
Incomplete question?
- A: Option A
- B: Option B
```spoiler-block:
TODO
```
""")
stats = ImportStats()
result = import_question_file(question_file, tmp_path, stats, force=True)
assert result == 'skipped_todo'
assert stats.questions_with_todo == 1
assert Question.objects.filter(text='Incomplete question?').count() == 0
def test_mtime_tracking(self, tmp_path):
"""Test that file modification time is tracked"""
question_file = tmp_path / "question4.md"
question_file.write_text("""---
tags: [frågetyp/scq]
---
What is the correct answer?
**Välj ett alternativ:**
- A: Answer A
- B: Answer B
```spoiler-block:
A
```
""")
stats = ImportStats()
result = import_question_file(question_file, tmp_path, stats, force=True)
# Verify import succeeded
assert result in ['imported', 'updated'], f"Import failed with status: {result}"
assert stats.created == 1, f"Expected 1 created, got {stats.created}"
question = Question.objects.get(text='What is the correct answer?')
assert question.file_mtime is not None
assert question.file_mtime == question_file.stat().st_mtime
def test_update_existing_question(self, tmp_path):
"""Test updating an existing question"""
question_file = tmp_path / "question5.md"
# Initial import
question_file.write_text("""---
tags: [frågetyp/scq]
---
What is the original question here?
**Välj ett alternativ:**
- A: First answer
- B: Second answer
```spoiler-block:
A
```
""")
stats1 = ImportStats()
result1 = import_question_file(question_file, tmp_path, stats1, force=True)
assert result1 in ['imported', 'updated'], f"Initial import failed: {result1}"
assert stats1.created == 1
# Update the file
import time
time.sleep(0.1) # Ensure mtime changes
question_file.write_text("""---
tags: [frågetyp/scq]
---
What is the original question here?
**Välj ett alternativ:**
- A: First answer
- B: Second answer
- C: Third option
```spoiler-block:
C
```
""")
stats2 = ImportStats()
result = import_question_file(question_file, tmp_path, stats2, force=False)
assert result == 'updated'
assert stats2.updated == 1
# Verify update
question = Question.objects.get(text='What is the original question here?')
assert question.correct_answer == 'C'
assert question.options.count() == 3
@pytest.mark.django_db
@pytest.mark.import_tests
class TestImportStatistics:
"""Test import statistics tracking"""
def test_statistics_aggregation(self, tmp_path):
"""Test that statistics are correctly aggregated"""
# Create multiple question files
(tmp_path / "folder1").mkdir()
(tmp_path / "folder2").mkdir()
(tmp_path / "folder1" / "q1.md").write_text("""---
tags: [frågetyp/mcq]
---
Question number one?
**Välj två alternativ:**
- A: Answer A
- B: Answer B
```spoiler-block:
A
```
""")
(tmp_path / "folder1" / "q2.md").write_text("""---
tags: [frågetyp/scq]
---
Question number two?
**Välj ett alternativ:**
- A: Answer A
```spoiler-block:
TODO
```
""")
(tmp_path / "folder2" / "q3.md").write_text("""---
tags: [notes]
---
Not a question, just notes
""")
from quiz.utils.importer import import_questions
stats = import_questions(tmp_path, tmp_path, force=True)
assert stats.total_files == 3
assert stats.mcq_questions == 2
assert stats.questions_with_answers == 1
assert stats.questions_with_todo == 1
assert stats.non_mcq_skipped == 1
@pytest.mark.django_db
class TestEdgeCases:
"""Test edge cases and error handling"""
def test_malformed_frontmatter(self):
"""Test handling of malformed frontmatter"""
content = """---
tags: [frågetyp/scq]
date: broken
---
Question?
- A: Answer
```spoiler-block:
A
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
# Should still parse as question if tags are recognizable
assert is_question is True
def test_missing_spoiler_block(self):
"""Test question without spoiler block"""
content = """---
tags: [frågetyp/scq]
---
Question without answer?
- A: Option A
- B: Option B
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data['has_answer'] is False
def test_empty_spoiler_block(self):
"""Test question with empty spoiler block"""
content = """---
tags: [frågetyp/scq]
---
Question with empty answer block?
**Välj ett alternativ:**
- A: Option A
```spoiler-block:
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert is_question is True
assert data.get('has_answer') is False
def test_special_characters_in_text(self):
"""Test handling of special characters"""
content = """---
tags: [frågetyp/scq]
---
What about "quotes" & <html> tags?
- A: Option with åäö
- B: Option with émojis 🎉
```spoiler-block:
A
```
"""
is_question, data = parse_markdown_question(Path("test.md"), content)
assert '"quotes"' in data['text']
assert 'åäö' in data['options'][0][1]

View File

@@ -1,80 +0,0 @@
from django.test import TestCase, Client
from django.urls import reverse
from quiz.models import Course, Tag, Question, Exam
from quiz.forms import CreateQuizForm
class QuizCreationTests(TestCase):
def setUp(self):
self.course1 = Course.objects.create(name="Course 1", code="C1")
self.course2 = Course.objects.create(name="Course 2", code="C2")
self.tag1 = Tag.objects.create(name="Tag 1", slug="tag-1")
self.tag2 = Tag.objects.create(name="Tag 2", slug="tag-2")
self.exam1 = Exam.objects.create(course=self.course1, date="2023-01-01")
self.exam2 = Exam.objects.create(course=self.course2, date="2023-01-02")
self.q1 = Question.objects.create(exam=self.exam1, text="Q1", correct_answer="A", file_path="p1")
self.q1.tags.add(self.tag1)
self.q2 = Question.objects.create(exam=self.exam2, text="Q2", correct_answer="A,B", file_path="p2")
self.q2.tags.add(self.tag2)
self.client = Client()
def test_create_quiz_form_valid(self):
form_data = {
'course': self.course1.id,
'tags': [self.tag1.id],
'question_type': ['single']
}
form = CreateQuizForm(data=form_data)
self.assertTrue(form.is_valid())
def test_create_quiz_view_post_updates_session(self):
response = self.client.post(reverse('create_quiz'), {
'course': self.course1.id,
'tags': [self.tag1.id],
'question_type': ['single']
})
self.assertRedirects(response, reverse('next_question'))
session = self.client.session
self.assertEqual(session['quiz_filter_course_id'], self.course1.id)
self.assertEqual(session['quiz_filter_tag_ids'], [self.tag1.id])
self.assertEqual(session['quiz_filter_types'], ['single'])
def test_get_next_question_respects_filters(self):
# Set session data manually
session = self.client.session
session['quiz_filter_course_id'] = self.course1.id
session.save()
# Should get Q1 (Course 1) but not Q2 (Course 2)
# Note: We need a user session for answered questions tracking,
# but the view handles anonymous users by creating a session or erroring?
# Let's check middleware. It seems middleware handles quiz_user creation.
response = self.client.get(reverse('next_question'))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Q1")
# Now change filter to Course 2
session['quiz_filter_course_id'] = self.course2.id
session.save()
response = self.client.get(reverse('next_question'))
self.assertContains(response, "Q2")
def test_filter_by_type(self):
session = self.client.session
session['quiz_filter_types'] = ['multi']
session.save()
response = self.client.get(reverse('next_question'))
self.assertContains(response, "Q2") # Q2 is multi (A,B)
session['quiz_filter_types'] = ['single']
session.save()
response = self.client.get(reverse('next_question'))
self.assertContains(response, "Q1") # Q1 is single (A)

260
quiz/uv.lock generated
View File

@@ -11,6 +11,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" },
{ url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" },
{ url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" },
{ url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" },
{ url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" },
{ url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" },
{ url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" },
{ url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" },
{ url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" },
{ url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" },
{ url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" },
{ url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" },
{ url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" },
{ url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" },
{ url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" },
{ url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" },
{ url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" },
{ url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" },
{ url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" },
{ url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" },
{ url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" },
{ url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" },
{ url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
{ url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
{ url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
{ url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
{ url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
{ url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
{ url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
{ url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
{ url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
{ url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
{ url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
{ url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
{ url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
{ url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
{ url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
{ url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
{ url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
{ url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
{ url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
{ url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
{ url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
{ url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
{ url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
{ url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
{ url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
]
[[package]]
name = "django"
version = "6.0"
@@ -25,23 +104,202 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181, upload-time = "2025-12-03T16:26:16.231Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "mistune"
version = "3.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pytest-cov"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
]
[[package]]
name = "pytest-django"
version = "4.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
name = "pytest-subtests"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" },
]
[[package]]
name = "python-frontmatter"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "quiz"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "django" },
{ name = "mistune" },
{ name = "python-frontmatter" },
{ name = "watchdog" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "pytest-mock" },
{ name = "pytest-subtests" },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = ">=6.0.0" },
{ name = "mistune", specifier = ">=3.1.4" },
{ name = "python-frontmatter", specifier = ">=1.1.0" },
{ name = "watchdog", specifier = ">=6.0.0" },
]
[package.metadata.requires-dev]
dev = []
dev = [
{ name = "pytest", specifier = ">=9.0.0" },
{ name = "pytest-cov", specifier = ">=7.0.0" },
{ name = "pytest-django", specifier = ">=4.11.1" },
{ name = "pytest-mock", specifier = ">=3.15.1" },
{ name = "pytest-subtests", specifier = ">=0.15.0" },
]
[[package]]
name = "sqlparse"