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,16 +11,20 @@ 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 summary .md file unless asked.
- Do not create a README.md file unless asked. - Do not create a README.md file unless asked.
- When installing a new dependency, prefer to use the latest version. - 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 ## Python
- Use uv for handling python installations - Use uv for handling python installations
- Use pyproject.toml to handle dependencies - Use pyproject.toml to handle dependencies
- Avoid exceptions if possible - Avoid exceptions if possible
- When exceptions are necessary, use specific exception types, provide meaningful messages, and handle them appropriately. - When exceptions are necessary, use specific exception types, provide meaningful messages, and handle them appropriately.
- Exceptions try/except blocks should be as narrow as possible, try extra hard to avoid catching exceptions you did not intend to catch. - Exceptions try/except blocks should be as narrow as possible, try extra hard to avoid catching exceptions you did not intend to catch.
- Use type hints for all functions and methods. - 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: ## 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. - Use pytest framework, version 9 or higher.
- Configure using pyproject.toml, avoid pytest.ini to be able to have all configuration in one place. - Configure using pyproject.toml, avoid pytest.ini to be able to have all configuration in one place.
- Prefer parametrized tests for functions that need to be tested with multiple sets of inputs and expected outputs. - Prefer parametrized tests for functions that need to be tested with multiple sets of inputs and expected outputs.
@@ -42,3 +46,11 @@ def test_parametrized(subtests: pytest.Subtests) -> None:
- Prefer function based views over class based views - Prefer function based views over class based views
- Use django-stubs for type hints - Use django-stubs for type hints
- Use pytest-django for testing django applications - 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", "id": "8dd584e60438200b",
"type": "tabs", "type": "tabs",
"dimension": 58.22050290135397,
"children": [ "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", "id": "b6de1b6650c09ff3",
"type": "leaf", "type": "leaf",
@@ -37,23 +23,25 @@
"title": "Statistik" "title": "Statistik"
} }
} }
], ]
"currentTab": 1
}, },
{ {
"id": "1ce7592fbb71e315", "id": "1ce7592fbb71e315",
"type": "tabs", "type": "tabs",
"dimension": 41.77949709864603,
"children": [ "children": [
{ {
"id": "2a734bede79968e0", "id": "063c080eabc776aa",
"type": "leaf", "type": "leaf",
"pinned": true,
"state": { "state": {
"type": "pdf", "type": "pdf",
"state": { "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", "icon": "lucide-file-text",
"title": "!2023-05-31-0100-DKS" "title": "!2024-01-10-0088-KOM"
} }
} }
] ]
@@ -103,7 +91,7 @@
} }
], ],
"direction": "horizontal", "direction": "horizontal",
"width": 200 "width": 264.50390243530273
}, },
"right": { "right": {
"id": "0948c66181b40af9", "id": "0948c66181b40af9",
@@ -227,42 +215,42 @@
}, },
"active": "b6de1b6650c09ff3", "active": "b6de1b6650c09ff3",
"lastOpenFiles": [ "lastOpenFiles": [
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/30.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/30.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/29.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0088-KOM.pdf",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/28.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/29.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/27.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/28.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/26.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/27.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/25.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/26.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/24.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/25.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/23.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/24.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/22.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/23.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/21.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/22.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/20.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/21.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/19.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/20.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/18.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/19.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/17.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/18.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/16.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/17.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/15.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/16.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/14.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/15.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/13.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/14.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/12.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/13.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/11.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/12.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/10.md", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/11.md",
"Anatomi & Histologi 2/Gamla tentor/2023-05-31/9.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/!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/1 Öga anatomi/Slides.pdf.pdf",
"Anatomi & Histologi 2/2 Öra anatomi/Organa sensum.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-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/Gamla tentor/2024-05-29/!2024-05-29-0125-GZX.pdf",
"Anatomi & Histologi 2/1 Öga anatomi/Organa sensum.pdf", "Anatomi & Histologi 2/1 Öga anatomi/Organa sensum.pdf",
"Anatomi & Histologi 2/2 Öra anatomi/Slides.pdf.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-121.png",
"attachments/image-120.png", "attachments/image-120.png",
"attachments/image-119.png", "attachments/image-119.png",
@@ -272,7 +260,6 @@
"attachments/image-115.png", "attachments/image-115.png",
"attachments/image-114.png", "attachments/image-114.png",
"attachments/image-113.png", "attachments/image-113.png",
"attachments/image-112.png",
"Untitled.canvas", "Untitled.canvas",
"Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas", "Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas",
"Biokemi/Metabolism/📋 Metabolismen översikt.canvas", "Biokemi/Metabolism/📋 Metabolismen översikt.canvas",

View File

@@ -5,15 +5,18 @@ date: 2024-01-10
Dra funktion till rätt lob: (1p) Dra funktion till rätt lob: (1p)
Motorik Motorik
Lobus frontalis
Somatosensorik Somatosensorik
Syn Syn
Lobus occipitalis
Hörsel Hörsel
Lobus temporalis
Lobus frontalis
Lobus occipitalis
Lobus parietalis Lobus parietalis
Lobus temporalis
```spoiler-block: ```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 date: 2024-01-10
--- ---
![[image-49.png]] ![[image-49.png]]
Vilken siffra motsvarar medulla oblongata? Vilken siffra motsvarar medulla oblongata? (1..13)
```spoiler-block: ```spoiler-block:
TODO 11
``` ```

View File

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

View File

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

View File

@@ -8,9 +8,9 @@ tags:
date: 2024-01-10 date: 2024-01-10
--- ---
![[image-54.png|330x140]] ![[image-54.png|330x140]]
Vilken bokstav markerar radix anterius (ventralroten)? Vilken bokstav markerar radix anterius (ventralroten)? (A..E)
A (1p) A (1p)
```spoiler-block: ```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) Bilden visar cortex i cerebrum (råtta). Rutan ligger i lamina .... (fyll i resten) (1p)
```spoiler-block: ```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_ _0,5p per rätt svar, inga avdrag för fel - totalt 1p_
```spoiler-block: ```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) Vilket alternativ stämmer bäst på cortex cerebelli? (1p)
**Välj ett alternativ:** **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: ```spoiler-block:
TODO C
``` ```

View File

@@ -8,7 +8,8 @@ I vilken av följande lober återfinns Hippocampus och uncus? (1p)
- A: Lobus occipitalis - A: Lobus occipitalis
- B: Lobus temporalis - B: Lobus temporalis
- C: Lobus frontalis - C: Lobus frontalis
- D: Lobus parietalis
-
```spoiler-block: ```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 - C: Discus opticus/papilla
```spoiler-block: ```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 - C: De bipolära neuronens axoner löper genom synnerven
```spoiler-block: ```spoiler-block:
TODO A
``` ```

View File

@@ -3,10 +3,23 @@ tags: [ah2, provfråga, frågetyp/dnd-text, öga, histologi]
date: 2024-01-10 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) 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: Anteriort:
Crystalliner enkelt skivepitel fibroblaster a) längst fram
b) mitten
c) längst bak
```spoiler-block: ```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 date: 2024-01-10
--- ---
Vad är rätt om iris? (1p) Vad är rätt om iris? (1p)
**Välj ett alternativ:** **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: ```spoiler-block:
TODO B
``` ```

View File

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

View File

@@ -12,11 +12,13 @@ date: 2024-01-10
![[image-61.png]] ![[image-61.png]]
Vilken bokstav pekar på 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) (båda ska vara rätt för att få poäng) (1p)
```spoiler-block: ```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 - D: Håcellerna aktiveras när de apikala utskotten böjs, vilket leder till inflöde av K-joner i cellen
```spoiler-block: ```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 - D: I cerebrum återfinns fasciculi, dels inom en hjärnhalva, dels mellan hjärnhalvorna
```spoiler-block: ```spoiler-block:
TODO C
``` ```

View File

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

View File

@@ -10,10 +10,14 @@ Metathalamus
Epithalamus Epithalamus
ANS och Endokrin styrning ANS och Endokrin styrning
Melatoninproduktion
Omkoppling för cerebral afferens Omkoppling för cerebral afferens
Omkoppling synbanor Omkoppling synbanor
Melatoninproduktion
```spoiler-block: ```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. - 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 - 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 - 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: ```spoiler-block:
TODO A
``` ```

View File

@@ -11,5 +11,5 @@ Vilken struktur binder samman Cerebellum med övriga CNS? (1p)
- D: Basis pontis - D: Basis pontis
```spoiler-block: ```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 - D: Pyramis
```spoiler-block: ```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 - D: Nedre delen av ryggmärgen benämns conus och från dem hänger det ned lumbosakrala spinalnerver som en
```spoiler-block: ```spoiler-block:
TODO D
``` ```

View File

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

View File

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

BIN
quiz/.coverage Normal file

Binary file not shown.

View File

@@ -21,6 +21,9 @@ def django_db_setup():
'NAME': None, 'NAME': None,
}, },
} }
settings.PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
@pytest.fixture @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" requires-python = ">=3.13"
dependencies = [ dependencies = [
"django>=6.0.0", "django>=6.0.0",
"mistune>=3.1.4",
"python-frontmatter>=1.1.0",
"watchdog>=6.0.0", "watchdog>=6.0.0",
] ]
@@ -13,5 +15,11 @@ requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.uv] [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 --strict-markers
--tb=short --tb=short
--reuse-db --reuse-db
testpaths = tests testpaths = .
markers = markers =
admin: Admin interface tests admin: Admin interface tests
import: Import and parsing tests import: Import and parsing tests

View File

@@ -17,7 +17,7 @@ class TestAdminPages:
email='admin@test.com', email='admin@test.com',
password='admin123' password='admin123'
) )
client.login(username='testadmin', password='admin123') client.force_login(admin_user)
return client return client
@pytest.fixture @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""" """Set up test data"""
self.client = Client() self.client = Client()
# Create test user (QuizUser uses session_key, not username) # Ensure session exists and get its key
self.user = QuizUser.objects.create(session_key="test_session_key_123") 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 # Create test course
self.course = Course.objects.create(name="Test Course") self.course = Course.objects.create(name="Test Course")
@@ -24,14 +29,15 @@ class QuizViewsTestCase(TestCase):
) )
# Create test tags # Create test tags
self.tag1 = Tag.objects.create(name="Tag 1") self.tag1 = Tag.objects.create(name="Tag 1", slug="tag-1")
self.tag2 = Tag.objects.create(name="Tag 2") self.tag2 = Tag.objects.create(name="Tag 2", slug="tag-2")
# Create test questions # Create test questions
self.question1 = Question.objects.create( self.question1 = Question.objects.create(
text="Test question 1?", text="Test question 1?",
correct_answer="A", correct_answer="A",
exam=self.exam exam=self.exam,
file_path="test1.md"
) )
self.question1.tags.add(self.tag1) self.question1.tags.add(self.tag1)
@@ -41,7 +47,8 @@ class QuizViewsTestCase(TestCase):
self.question2 = Question.objects.create( self.question2 = Question.objects.create(
text="Test question 2 (multi)?", text="Test question 2 (multi)?",
correct_answer="A,B", correct_answer="A,B",
exam=self.exam exam=self.exam,
file_path="test2.md"
) )
self.question2.tags.add(self.tag1, self.tag2) self.question2.tags.add(self.tag1, self.tag2)
@@ -58,7 +65,7 @@ class QuizViewsTestCase(TestCase):
"""Test dashboard index view""" """Test dashboard index view"""
response = self.client.get(reverse('index')) response = self.client.get(reverse('index'))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, 'Välkommen') self.assertContains(response, 'Snabbstart')
self.assertIn('active_sessions', response.context) self.assertIn('active_sessions', response.context)
self.assertIn('form', 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" }, { 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]] [[package]]
name = "django" name = "django"
version = "6.0" 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" }, { 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]] [[package]]
name = "quiz" name = "quiz"
version = "0.1.0" version = "0.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "django" }, { name = "django" },
{ name = "mistune" },
{ name = "python-frontmatter" },
{ name = "watchdog" }, { name = "watchdog" },
] ]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "pytest-django" },
{ name = "pytest-mock" },
{ name = "pytest-subtests" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "django", specifier = ">=6.0.0" }, { 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" }, { name = "watchdog", specifier = ">=6.0.0" },
] ]
[package.metadata.requires-dev] [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]] [[package]]
name = "sqlparse" name = "sqlparse"