vault backup: 2025-12-23 16:41:40
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m47s
All checks were successful
Deploy Quartz site to GitHub Pages / build (push) Successful in 1m47s
This commit is contained in:
12
AGENT.md
12
AGENT.md
@@ -11,6 +11,7 @@ The rest of this document describes coding conventions to follow when writing co
|
|||||||
- Do not create a summary .md file unless asked.
|
- Do not create a 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
|
||||||
@@ -19,8 +20,11 @@ The rest of this document describes coding conventions to follow when writing co
|
|||||||
- When exceptions are necessary, use specific exception types, provide meaningful messages, and handle them appropriately.
|
- 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.
|
||||||
|
|||||||
89
content/.obsidian/workspace.json
vendored
89
content/.obsidian/workspace.json
vendored
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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]]
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ Vilken kranialnerv förmedlar ansiktes sensorik? (1p)
|
|||||||
- D: N Facialis
|
- D: N Facialis
|
||||||
|
|
||||||
```spoiler-block:
|
```spoiler-block:
|
||||||
TODO
|
A
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
BIN
quiz/.coverage
Normal file
Binary file not shown.
@@ -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.
@@ -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",
|
||||||
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
|
||||||
85
quiz/quiz/tests/test_quiz_creation.py
Normal file
85
quiz/quiz/tests/test_quiz_creation.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|
||||||
0
quiz/quiz/utils/tests/__init__.py
Normal file
0
quiz/quiz/utils/tests/__init__.py
Normal file
261
quiz/quiz/utils/tests/test_importer.py
Normal file
261
quiz/quiz/utils/tests/test_importer.py
Normal 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'
|
||||||
187
quiz/quiz/utils/tests/test_unified_parser.py
Normal file
187
quiz/quiz/utils/tests/test_unified_parser.py
Normal 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
|
||||||
465
quiz/quiz/utils/unified_parser.py
Normal file
465
quiz/quiz/utils/unified_parser.py
Normal 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
|
||||||
|
)
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# This makes tests a package
|
|
||||||
|
|
||||||
@@ -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]
|
|
||||||
|
|
||||||
@@ -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
260
quiz/uv.lock
generated
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user