diff --git a/AGENT.md b/AGENT.md index ba9e601..bf20aed 100644 --- a/AGENT.md +++ b/AGENT.md @@ -11,16 +11,20 @@ The rest of this document describes coding conventions to follow when writing co - Do not create a summary .md file unless asked. - Do not create a README.md file unless asked. - When installing a new dependency, prefer to use the latest version. +- Try to reduce indentation, prefer to use early returns or continue everwhere, be excessive about it. ## Python - Use uv for handling python installations - Use pyproject.toml to handle dependencies - Avoid exceptions if possible -- When exceptions are necessary, use specific exception types, provide meaningful messages, and handle them appropriately. +- When exceptions are necessary, use specific exception types, provide meaningful messages, and handle them appropriately. - Exceptions try/except blocks should be as narrow as possible, try extra hard to avoid catching exceptions you did not intend to catch. - Use type hints for all functions and methods. +- Use global constants for compiled regex patterns, capitalized and ending with `_RE`. +- Document regex patterns with inline comments using multiple strings inside `re.compile()`. This overrides the general rule against docstrings/comments. ## Python unit testing: +- we should have a tests package under each implementation that has a test and the corresponding test foo/bar.py is tested in foo/bar/tests/test_bar.py - Use pytest framework, version 9 or higher. - Configure using pyproject.toml, avoid pytest.ini to be able to have all configuration in one place. - Prefer parametrized tests for functions that need to be tested with multiple sets of inputs and expected outputs. @@ -42,3 +46,11 @@ def test_parametrized(subtests: pytest.Subtests) -> None: - Prefer function based views over class based views - Use django-stubs for type hints - Use pytest-django for testing django applications +- Avoid comments in settings. + +## Style Learnings +- **Stateful Parsing**: For complex tasks like parsing, prefer stateful classes that share context via `self` to avoid passing many arguments. +- **Match Statement Inlining**: Prefer inlining simple, single-statement logic directly into `match` cases. +- **Semantic Naming**: Use names that reflect the domain (e.g. `answer`) rather than implementation details (e.g. `spoiler`). +- **Data Propagation**: Ensure all calculated metrics (like question counts) are consistently propagated to the final output structure. +- **Factory Helpers**: Use centralized factory methods (e.g. `_create_question`) to instantiate objects with many shared common fields.- **Test Organization**: Place tests in a `tests` package within each implementation directory. Test files should be named `test_xxx.py` corresponding to the `xxx.py` file they test. diff --git a/content/.obsidian/workspace.json b/content/.obsidian/workspace.json index 025580a..4b781b3 100644 --- a/content/.obsidian/workspace.json +++ b/content/.obsidian/workspace.json @@ -6,22 +6,8 @@ { "id": "8dd584e60438200b", "type": "tabs", + "dimension": 58.22050290135397, "children": [ - { - "id": "baa45c5e57825965", - "type": "leaf", - "state": { - "type": "markdown", - "state": { - "file": "Anatomi & Histologi 2/2 Öra anatomi/Video 1.md", - "mode": "source", - "source": false, - "backlinks": false - }, - "icon": "lucide-file", - "title": "Video 1" - } - }, { "id": "b6de1b6650c09ff3", "type": "leaf", @@ -37,23 +23,25 @@ "title": "Statistik" } } - ], - "currentTab": 1 + ] }, { "id": "1ce7592fbb71e315", "type": "tabs", + "dimension": 41.77949709864603, "children": [ { - "id": "2a734bede79968e0", + "id": "063c080eabc776aa", "type": "leaf", + "pinned": true, "state": { "type": "pdf", "state": { - "file": "Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf" + "file": "Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0088-KOM.pdf" }, + "pinned": true, "icon": "lucide-file-text", - "title": "!2023-05-31-0100-DKS" + "title": "!2024-01-10-0088-KOM" } } ] @@ -103,7 +91,7 @@ } ], "direction": "horizontal", - "width": 200 + "width": 264.50390243530273 }, "right": { "id": "0948c66181b40af9", @@ -227,42 +215,42 @@ }, "active": "b6de1b6650c09ff3", "lastOpenFiles": [ - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/30.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/29.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/28.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/27.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/26.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/25.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/24.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/23.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/22.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/21.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/20.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/19.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/18.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/17.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/16.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/15.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/14.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/13.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/12.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/11.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/10.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/9.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/30.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0088-KOM.pdf", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/29.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/28.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/27.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/26.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/25.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/24.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/23.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/22.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/21.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/20.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/19.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/18.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/17.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/16.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/15.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/14.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/13.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/12.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/11.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/10.md", + "attachments/image-48.png", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/9.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/8.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/7.md", + "Anatomi & Histologi 2/Gamla tentor/2024-01-10/6.md", + "Biokemi/Plasmidlabb/Articles/Report guidelines 2025.pdf", + "Biokemi/Plasmidlabb/Protokoll.pdf", "Anatomi & Histologi 2/Gamla tentor/2023-05-31/!2023-05-31-0100-DKS.pdf", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/8.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/7.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/6.md", - "Anatomi & Histologi 2/Gamla tentor/2023-05-31/5.md", "Anatomi & Histologi 2/1 Öga anatomi/Slides.pdf.pdf", "Anatomi & Histologi 2/2 Öra anatomi/Organa sensum.pdf", "Anatomi & Histologi 2/Gamla tentor/2024-01-10/!2024-01-10-0009-RYY.pdf", "Anatomi & Histologi 2/Gamla tentor/2024-05-29/!2024-05-29-0125-GZX.pdf", "Anatomi & Histologi 2/1 Öga anatomi/Organa sensum.pdf", "Anatomi & Histologi 2/2 Öra anatomi/Slides.pdf.pdf", - "Anatomi & Histologi 2/Gamla tentor/2023-01-11/!2023-01-11-0044-PRX.pdf", - "Anatomi & Histologi 2/Gamla tentor/2022-06-01/!2022-06-01-0101-MGY.pdf", - "Anatomi & Histologi 2/Gamla tentor/2022-01-15/!2022-01-15-0032-BWD.pdf", "attachments/image-121.png", "attachments/image-120.png", "attachments/image-119.png", @@ -272,7 +260,6 @@ "attachments/image-115.png", "attachments/image-114.png", "attachments/image-113.png", - "attachments/image-112.png", "Untitled.canvas", "Biokemi/Metabolism/👋 Introduktion till metabolismen/Untitled.canvas", "Biokemi/Metabolism/📋 Metabolismen översikt.canvas", diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/1.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/1.md index 839d24d..a297fd7 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/1.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/1.md @@ -5,15 +5,18 @@ date: 2024-01-10 Dra funktion till rätt lob: (1p) Motorik -Lobus frontalis Somatosensorik Syn - -Lobus occipitalis Hörsel -Lobus temporalis + +Lobus frontalis +Lobus occipitalis Lobus parietalis +Lobus temporalis ```spoiler-block: -TODO +Motorik: Lobus frontalis +Somatosensorik: Lobus parietalis +Syn: Lobus occipitalis +Hörsel: Lobus temporalis ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/11.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/11.md index fa55e72..971527c 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/11.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/11.md @@ -8,8 +8,8 @@ tags: date: 2024-01-10 --- ![[image-49.png]] -Vilken siffra motsvarar medulla oblongata? +Vilken siffra motsvarar medulla oblongata? (1..13) ```spoiler-block: -TODO +11 ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/12.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/12.md index 0d5933c..c0ff25a 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/12.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/12.md @@ -8,8 +8,9 @@ tags: date: 2024-01-10 --- ![[image-50.png]] -Vilken siffra pekar på thalamus? (1p) +Vilken siffra pekar på thalamus? (1..21) +(1p) ```spoiler-block: -TODO +19 ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/14.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/14.md index 4fbac27..0613e8b 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/14.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/14.md @@ -12,5 +12,5 @@ date: 2024-01-10 Markera var du återfinner barkområde för synintryck: (1p) ```spoiler-block: -TODO![[image-53.png]] +![[image-53.png]] ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/15.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/15.md index 62a755a..105a42a 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/15.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/15.md @@ -8,9 +8,9 @@ tags: date: 2024-01-10 --- ![[image-54.png|330x140]] -Vilken bokstav markerar radix anterius (ventralroten)? +Vilken bokstav markerar radix anterius (ventralroten)? (A..E) A (1p) ```spoiler-block: -TODO +A ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/16.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/16.md index d8effe3..e7262b5 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/16.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/16.md @@ -12,5 +12,5 @@ date: 2024-01-10 Bilden visar cortex i cerebrum (råtta). Rutan ligger i lamina .... (fyll i resten) (1p) ```spoiler-block: -TODO +granulis interna ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/17.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/17.md index bf1f9dc..213a3d4 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/17.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/17.md @@ -30,5 +30,6 @@ b) Vilken av följande celltyper har också sin cellkropp i samma område (men _0,5p per rätt svar, inga avdrag för fel - totalt 1p_ ```spoiler-block: -TODO +a) astrocyter +b) oligodendrocyter ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/18.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/18.md index 23b94e3..a8553e6 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/18.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/18.md @@ -5,8 +5,11 @@ date: 2024-01-10 Vilket alternativ stämmer bäst på cortex cerebelli? (1p) **Välj ett alternativ:** -- A: korncellernas axoner leder information ut ur cerebellum stjärnceller har sin cellkropp i l. granularis purkinjeceller skickar signaler från cortex +- A: korncellernas axoner leder information ut ur cerebellum +- B: stjärnceller har sin cellkropp i l. granularis +- C: purkinjeceller skickar signaler från cortex +- D: purkinjecellerna har sina dendriter i l. granularis ```spoiler-block: -TODO +C ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/2.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/2.md index 4e35096..a9d1e45 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/2.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/2.md @@ -8,7 +8,8 @@ I vilken av följande lober återfinns Hippocampus och uncus? (1p) - A: Lobus occipitalis - B: Lobus temporalis - C: Lobus frontalis - +- D: Lobus parietalis +- ```spoiler-block: -TODO +B ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/20.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/20.md index c3b851d..9ac240b 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/20.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/20.md @@ -10,5 +10,5 @@ I vilken del av [retinae](https://en.wikipedia.org/wiki/Retina) återfinner du v - C: Discus opticus/papilla ```spoiler-block: -TODO +C ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/22.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/22.md index 0ac7609..c94d7a1 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/22.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/22.md @@ -10,5 +10,5 @@ Vad är rätt om retinas bipolära neuron? (1p) - C: De bipolära neuronens axoner löper genom synnerven ```spoiler-block: -TODO +A ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/23.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/23.md index 8437110..f81f366 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/23.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/23.md @@ -3,10 +3,23 @@ tags: [ah2, provfråga, frågetyp/dnd-text, öga, histologi] date: 2024-01-10 --- Linsen består av olika komponenter. Välj tre av nedan komponenter och ordna i rätt ordning från anteriort (närmast iris) till mitten av linsen: (1p) - Hjälp kollagenrikt lager endotel enkelt kubiskt epitel nervändslut + +- endotel +- enkelt kubiskt +- enkelt skivepitel +- epitel nervändslut +- fibroblaster +- kollagenrikt lager +- Crystalliner + Anteriort: -Crystalliner enkelt skivepitel fibroblaster +a) längst fram +b) mitten +c) längst bak + ```spoiler-block: -TODO +a) kollagenrikt lager +b) enkelt kubiskt epitel +c) Crystalliner ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/24.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/24.md index 7a75a60..263c6b5 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/24.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/24.md @@ -1,12 +1,15 @@ --- -tags: [ah2, provfråga, frågetyp/mcq, öga, histologi] +tags: [ah2, provfråga, frågetyp/scq, öga, histologi] date: 2024-01-10 --- Vad är rätt om iris? (1p) **Välj ett alternativ:** -- A: fäster till linsen med hjälp av zonulatrådar är den anteriora (främre) förlängningen av ciliarkroppen bekläds av flerskiktat oförhornat epitel på framsidan +- A: fäster till linsen med hjälp av zonulatrådar +- B: är den anteriora (främre) förlängningen av ciliarkroppen +- C: bekläds av flerskiktat oförhornat epitel på framsidan +- D: bekläds av pigmenterat epitel på framsidan ```spoiler-block: -TODO +B ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/25.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/25.md index d9453fc..41dc68a 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/25.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/25.md @@ -16,7 +16,8 @@ Vilken kranialnerv förmedlar hörsel? (1p) - I: N X - J: N XI - K: N I +- L: VIII ```spoiler-block: -TODO +L ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/28.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/28.md index 6d1e537..0760d1a 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/28.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/28.md @@ -12,11 +12,13 @@ date: 2024-01-10 ![[image-61.png]] Vilken bokstav pekar på -a) membranet som skiljer scala media från scala vestibularis ? +a) membranet som skiljer scala media från scala vestibularis ? (A..I) + +b) sensoriska celler som kontaktar tektorialmembranet? (A..I) -b) sensoriska celler som kontaktar tektorialmembranet? (båda ska vara rätt för att få poäng) (1p) ```spoiler-block: -TODO +a) B +b) E ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/29.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/29.md index 8b253d5..f68aea5 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/29.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/29.md @@ -11,5 +11,5 @@ Vilka påståenden stämmer på innerörats hårceller? (1p) - D: Håcellerna aktiveras när de apikala utskotten böjs, vilket leder till inflöde av K-joner i cellen ```spoiler-block: -TODO +D ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/3.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/3.md index 637f82c..830a17d 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/3.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/3.md @@ -11,5 +11,5 @@ Vilket påstående beskriver bäst storhjärnans vita substans? (1p) - D: I cerebrum återfinns fasciculi, dels inom en hjärnhalva, dels mellan hjärnhalvorna ```spoiler-block: -TODO +C ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/30.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/30.md index a025caa..df46fe6 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/30.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/30.md @@ -2,12 +2,20 @@ tags: [ah2, provfråga, frågetyp/dnd-text, öra, histologi] date: 2024-01-10 --- -Vilken epiteltyp bekläder de delar av örat som listas nedan? (Dra rätt epitel till rätt del): (1p) cylinderepitel med mikrovilli Flerskiktad förhornad epitel -Trumhinna utsida flerskiktad oförhornad skivepitel -Mellanörat -Enkelt kubiskt epitel -Tuba auditiva flerradigt cilierad epitel +Vilken epiteltyp bekläder de delar av örat som listas nedan? (Dra rätt epitel till rätt del): (1p) + +- cylinderepitel med mikrovilli +- enkelt kubiskt epitel +- flerradigt cilierad epitel +- flerskiktad förhornad epitel +- flerskiktad oförhornad skivepitel + +a) Trumhinna utsida +b) Mellanörat +c) Tuba auditiva ```spoiler-block: -TODO +a) flerskiktad förhornad epitel +b) enkelt kubiskt epitel +c) flerradigt cilierad epitel ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/4.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/4.md index 6af8fdb..3e32aae 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/4.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/4.md @@ -10,10 +10,14 @@ Metathalamus Epithalamus ANS och Endokrin styrning +Melatoninproduktion Omkoppling för cerebral afferens Omkoppling synbanor -Melatoninproduktion + ```spoiler-block: -TODO +Hypothalamus: ANS och Endokrin styrning +Thalamus: Omkoppling för cerebral afferens +Metathalamus: Omkoppling synbanor +Epithalamus: Melatoninproduktion ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/5.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/5.md index 58ba855..b900102 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/5.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/5.md @@ -8,7 +8,8 @@ Vilket påstående beskriver bäst cerebellum? (1p) - A: Cerebellum är kraftigt veckad i sprickor och blad (foliae et fissurae) med vit substans där innanför samt cerebellära kärnor på djupet. - B: Cerebellum styr kroppens balans och koordination via egna motoriska/efferenta banor - C: Cerebellum består av två halvor, vilka binds samman av corpus callosum +- D: Cerebellum är veckad som storhjärnan, men är både mindre och innehåller klart färre neuron. ```spoiler-block: -TODO +A ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/6.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/6.md index 28c61a1..786abb5 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/6.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/6.md @@ -11,5 +11,5 @@ Vilken struktur binder samman Cerebellum med övriga CNS? (1p) - D: Basis pontis ```spoiler-block: -TODO +A ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/7.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/7.md index 74f9cff..9eac932 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/7.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/7.md @@ -11,5 +11,5 @@ Vilken av följande strukturer finner du i hjärnstammens övre del? (1p) - D: Pyramis ```spoiler-block: -TODO +C ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/8.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/8.md index 2968aa6..bb84066 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/8.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/8.md @@ -11,5 +11,5 @@ Vilket påstående beskriver bäst ryggmärgens anatomi? (1p) - D: Nedre delen av ryggmärgen benämns conus och från dem hänger det ned lumbosakrala spinalnerver som en ```spoiler-block: -TODO +D ``` diff --git a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/9.md b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/9.md index f89aa1a..fe06406 100644 --- a/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/9.md +++ b/content/Anatomi & Histologi 2/Gamla tentor/2024-01-10/9.md @@ -11,5 +11,5 @@ Vilken kranialnerv förmedlar ansiktes sensorik? (1p) - D: N Facialis ```spoiler-block: -TODO +A ``` diff --git a/content/Anatomi & Histologi 2/Statistik.md b/content/Anatomi & Histologi 2/Statistik.md index 70237df..f264889 100644 --- a/content/Anatomi & Histologi 2/Statistik.md +++ b/content/Anatomi & Histologi 2/Statistik.md @@ -6,7 +6,7 @@ | 2022-06-01 | ✅ | ✅ | ✅ | | ✅ | ✅ | | | 2023-01-11 | ✅ | ✅ | ✅ | | ✅ | ✅ | | | 2023-05-31 | ✅ | ✅ | ✅ | | ✅ | ✅ | | -| 2024-01-10 | ✅ | ✅ | ✅ | | ✅ | | | +| 2024-01-10 | ✅ | ✅ | ✅ | | ✅ | ✅ | | | 2024-05-29 | ✅ | ✅ | ✅ | | ✅ | | | | 2025-01-15 | ✅ | ✅ | ✅ | | ✅ | | | | 2025-02-08 | ✅ | ✅ | ✅ | | ✅ | | | diff --git a/quiz/.coverage b/quiz/.coverage new file mode 100644 index 0000000..d9b53c9 Binary files /dev/null and b/quiz/.coverage differ diff --git a/quiz/tests/conftest.py b/quiz/conftest.py similarity index 95% rename from quiz/tests/conftest.py rename to quiz/conftest.py index 33b5b37..0a0c35e 100644 --- a/quiz/tests/conftest.py +++ b/quiz/conftest.py @@ -21,6 +21,9 @@ def django_db_setup(): 'NAME': None, }, } + settings.PASSWORD_HASHERS = [ + 'django.contrib.auth.hashers.MD5PasswordHasher', + ] @pytest.fixture diff --git a/quiz/db.sqlite3-wal b/quiz/db.sqlite3-wal index e69de29..6721095 100644 Binary files a/quiz/db.sqlite3-wal and b/quiz/db.sqlite3-wal differ diff --git a/quiz/pyproject.toml b/quiz/pyproject.toml index b99bf42..2241eed 100644 --- a/quiz/pyproject.toml +++ b/quiz/pyproject.toml @@ -5,6 +5,8 @@ description = "Medical quiz application with auto-import from Obsidian" requires-python = ">=3.13" dependencies = [ "django>=6.0.0", + "mistune>=3.1.4", + "python-frontmatter>=1.1.0", "watchdog>=6.0.0", ] @@ -13,5 +15,11 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.uv] -dev-dependencies = [] +dev-dependencies = [ + "pytest>=9.0.0", + "pytest-cov>=7.0.0", + "pytest-django>=4.11.1", + "pytest-mock>=3.15.1", + "pytest-subtests>=0.15.0", +] diff --git a/quiz/pytest.ini b/quiz/pytest.ini index 06c8998..989bb5c 100644 --- a/quiz/pytest.ini +++ b/quiz/pytest.ini @@ -8,7 +8,7 @@ addopts = --strict-markers --tb=short --reuse-db -testpaths = tests +testpaths = . markers = admin: Admin interface tests import: Import and parsing tests diff --git a/quiz/tests/test_admin.py b/quiz/quiz/tests/test_admin.py similarity index 99% rename from quiz/tests/test_admin.py rename to quiz/quiz/tests/test_admin.py index 5954951..ea1d5a5 100644 --- a/quiz/tests/test_admin.py +++ b/quiz/quiz/tests/test_admin.py @@ -17,7 +17,7 @@ class TestAdminPages: email='admin@test.com', password='admin123' ) - client.login(username='testadmin', password='admin123') + client.force_login(admin_user) return client @pytest.fixture diff --git a/quiz/quiz/tests/test_matching_parser.py b/quiz/quiz/tests/test_matching_parser.py deleted file mode 100644 index 65e5be0..0000000 --- a/quiz/quiz/tests/test_matching_parser.py +++ /dev/null @@ -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) diff --git a/quiz/quiz/tests/test_quiz_creation.py b/quiz/quiz/tests/test_quiz_creation.py new file mode 100644 index 0000000..f32dd3c --- /dev/null +++ b/quiz/quiz/tests/test_quiz_creation.py @@ -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() diff --git a/quiz/quiz/tests.py b/quiz/quiz/tests/test_views.py similarity index 94% rename from quiz/quiz/tests.py rename to quiz/quiz/tests/test_views.py index 9190faf..8068e80 100644 --- a/quiz/quiz/tests.py +++ b/quiz/quiz/tests/test_views.py @@ -10,8 +10,13 @@ class QuizViewsTestCase(TestCase): """Set up test data""" self.client = Client() - # Create test user (QuizUser uses session_key, not username) - self.user = QuizUser.objects.create(session_key="test_session_key_123") + # Ensure session exists and get its key + s = self.client.session + s.save() + session_key = s.session_key + + # Create test user with the same session key + self.user = QuizUser.objects.create(session_key=session_key) # Create test course self.course = Course.objects.create(name="Test Course") @@ -24,14 +29,15 @@ class QuizViewsTestCase(TestCase): ) # Create test tags - self.tag1 = Tag.objects.create(name="Tag 1") - self.tag2 = Tag.objects.create(name="Tag 2") + self.tag1 = Tag.objects.create(name="Tag 1", slug="tag-1") + self.tag2 = Tag.objects.create(name="Tag 2", slug="tag-2") # Create test questions self.question1 = Question.objects.create( text="Test question 1?", correct_answer="A", - exam=self.exam + exam=self.exam, + file_path="test1.md" ) self.question1.tags.add(self.tag1) @@ -41,7 +47,8 @@ class QuizViewsTestCase(TestCase): self.question2 = Question.objects.create( text="Test question 2 (multi)?", correct_answer="A,B", - exam=self.exam + exam=self.exam, + file_path="test2.md" ) self.question2.tags.add(self.tag1, self.tag2) @@ -58,7 +65,7 @@ class QuizViewsTestCase(TestCase): """Test dashboard index view""" response = self.client.get(reverse('index')) self.assertEqual(response.status_code, 200) - self.assertContains(response, 'Välkommen') + self.assertContains(response, 'Snabbstart') self.assertIn('active_sessions', response.context) self.assertIn('form', response.context) diff --git a/quiz/quiz/utils/tests/__init__.py b/quiz/quiz/utils/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/quiz/quiz/utils/tests/test_importer.py b/quiz/quiz/utils/tests/test_importer.py new file mode 100644 index 0000000..142fd1e --- /dev/null +++ b/quiz/quiz/utils/tests/test_importer.py @@ -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' diff --git a/quiz/quiz/tests/test_question_parser.py b/quiz/quiz/utils/tests/test_question_parser.py similarity index 100% rename from quiz/quiz/tests/test_question_parser.py rename to quiz/quiz/utils/tests/test_question_parser.py diff --git a/quiz/quiz/utils/tests/test_unified_parser.py b/quiz/quiz/utils/tests/test_unified_parser.py new file mode 100644 index 0000000..1a9d139 --- /dev/null +++ b/quiz/quiz/utils/tests/test_unified_parser.py @@ -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 diff --git a/quiz/quiz/utils/unified_parser.py b/quiz/quiz/utils/unified_parser.py new file mode 100644 index 0000000..478e8b4 --- /dev/null +++ b/quiz/quiz/utils/unified_parser.py @@ -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 + ) diff --git a/quiz/tests/__init__.py b/quiz/tests/__init__.py deleted file mode 100644 index 20af3ab..0000000 --- a/quiz/tests/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# This makes tests a package - diff --git a/quiz/tests/test_import.py b/quiz/tests/test_import.py deleted file mode 100644 index 5302915..0000000 --- a/quiz/tests/test_import.py +++ /dev/null @@ -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" & 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] - diff --git a/quiz/tests/test_quiz_creation.py b/quiz/tests/test_quiz_creation.py deleted file mode 100644 index b42531c..0000000 --- a/quiz/tests/test_quiz_creation.py +++ /dev/null @@ -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) diff --git a/quiz/uv.lock b/quiz/uv.lock index 3d6704a..73a69b8 100644 --- a/quiz/uv.lock +++ b/quiz/uv.lock @@ -11,6 +11,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/be/317c2c55b8bbec407257d45f5c8d1b6867abc76d12043f2d3d58c538a4ea/asgiref-3.11.0-py3-none-any.whl", hash = "sha256:1db9021efadb0d9512ce8ffaf72fcef601c7b73a8807a1bb2ef143dc6b14846d", size = 24096, upload-time = "2025-11-19T15:32:19.004Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" }, + { url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" }, + { url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" }, + { url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" }, + { url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" }, + { url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" }, + { url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" }, + { url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" }, + { url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" }, + { url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" }, + { url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" }, + { url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" }, + { url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" }, + { url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" }, + { url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" }, + { url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" }, + { url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" }, + { url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" }, + { url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" }, + { url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" }, + { url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" }, + { url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" }, + { url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" }, + { url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" }, + { url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" }, + { url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" }, + { url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" }, +] + [[package]] name = "django" version = "6.0" @@ -25,23 +104,202 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/ae/f19e24789a5ad852670d6885f5480f5e5895576945fcc01817dfd9bc002a/django-6.0-py3-none-any.whl", hash = "sha256:1cc2c7344303bbfb7ba5070487c17f7fc0b7174bbb0a38cebf03c675f5f19b6d", size = 8339181, upload-time = "2025-12-03T16:26:16.231Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mistune" +version = "3.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/02/a7fb8b21d4d55ac93cdcde9d3638da5dd0ebdd3a4fed76c7725e10b81cbe/mistune-3.1.4.tar.gz", hash = "sha256:b5a7f801d389f724ec702840c11d8fc48f2b33519102fc7ee739e8177b672164", size = 94588, upload-time = "2025-08-29T07:20:43.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/f0/8282d9641415e9e33df173516226b404d367a0fc55e1a60424a152913abc/mistune-3.1.4-py3-none-any.whl", hash = "sha256:93691da911e5d9d2e23bc54472892aff676df27a75274962ff9edc210364266d", size = 53481, upload-time = "2025-08-29T07:20:42.218Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "pytest-django" +version = "4.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-subtests" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/d9/20097971a8d315e011e055d512fa120fd6be3bdb8f4b3aa3e3c6bf77bebc/pytest_subtests-0.15.0.tar.gz", hash = "sha256:cb495bde05551b784b8f0b8adfaa27edb4131469a27c339b80fd8d6ba33f887c", size = 18525, upload-time = "2025-10-20T16:26:18.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/64/bba465299b37448b4c1b84c7a04178399ac22d47b3dc5db1874fe55a2bd3/pytest_subtests-0.15.0-py3-none-any.whl", hash = "sha256:da2d0ce348e1f8d831d5a40d81e3aeac439fec50bd5251cbb7791402696a9493", size = 9185, upload-time = "2025-10-20T16:26:17.239Z" }, +] + +[[package]] +name = "python-frontmatter" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "quiz" version = "0.1.0" source = { editable = "." } dependencies = [ { name = "django" }, + { name = "mistune" }, + { name = "python-frontmatter" }, { name = "watchdog" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "pytest-django" }, + { name = "pytest-mock" }, + { name = "pytest-subtests" }, +] + [package.metadata] requires-dist = [ { name = "django", specifier = ">=6.0.0" }, + { name = "mistune", specifier = ">=3.1.4" }, + { name = "python-frontmatter", specifier = ">=1.1.0" }, { name = "watchdog", specifier = ">=6.0.0" }, ] [package.metadata.requires-dev] -dev = [] +dev = [ + { name = "pytest", specifier = ">=9.0.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-django", specifier = ">=4.11.1" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, + { name = "pytest-subtests", specifier = ">=0.15.0" }, +] [[package]] name = "sqlparse"