Skip to content

Commit 3c32e52

Browse files
authored
Merge pull request #22 from i-walk-away/feature/lesson_topics
feature/lesson_topics
2 parents 162a68b + b076a34 commit 3c32e52

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+807
-168
lines changed

CONTRIBUTING.md

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,63 @@ If you want to add a new lesson to pydantic quest, do this:
1212
* `lesson.yaml`
1313
* `theory.md`
1414
* `starter.py`
15-
* `cases.yaml`
15+
* `cases.yaml` -- *for theory-only lessons with no coding assignment, leave this file empty*
1616
3. Head to [lessons/index.yaml](lessons/index.yaml) and add the following:
1717

1818
```yaml
1919
- slug: dash-separated-lesson-name
20-
order: <integer>
20+
order: "<order-path>"
21+
no_code: false # or `true` if there is no assignment intended for the lesson
2122
```
2223
23-
The "order" field changes the order in which lessons appear in pydantic quest.
24-
If you're not sure, just use whatever highest order already exists in index and
25-
add +1 to it. I will reorder everything myself if needed :)
24+
The `slug` must exactly match the lesson directory name in [lessons/](lessons/).
25+
For example, this:
26+
27+
```yaml
28+
- slug: field-validators
29+
order: "1.1"
30+
```
31+
32+
must correspond to this directory:
33+
34+
```text
35+
lessons/field-validators/
36+
```
37+
38+
The `order` field controls lesson position in the UI and now supports
39+
hierarchical numbering.
40+
41+
Valid examples:
42+
43+
```yaml
44+
- slug: validators
45+
order: "1"
46+
- slug: field-validators
47+
order: "1.1"
48+
- slug: model-validators
49+
order: "1.2"
50+
- slug: models
51+
order: "2"
52+
- slug: basemodel
53+
order: "2.1"
54+
```
55+
56+
Rules:
57+
58+
- use positive numeric segments separated by dots
59+
- like this: `"1"`, `"1.1"`, `"2.3.4"`
60+
- every order value must be unique
61+
- set `no_code: true` for theory-only lessons with no coding task
62+
63+
Sorting is numeric by segment, so lessons appear like:
64+
65+
- `1`
66+
- `1.1`
67+
- `1.2`
68+
- `2`
69+
70+
If you're not sure where a lesson should go, just add it near the right section
71+
and i can reorder things myself later :)
2672

2773
## Explanation of the 4 neccessary files
2874

@@ -58,6 +104,8 @@ Test cases for your lesson. Just refer to [`lessons/lesson-template/cases.yaml`]
58104
You can find
59105
a *lot* of information there about how it works and how exactly to design your own test cases.
60106
Please inform me if it is still not very clear.
107+
If the lesson is marked `no_code: true`, this file is still required for consistency,
108+
but it can be empty, `{}`, or `cases: null`. The `run` button will be disabled in the UI.
61109

62110
## Contributor checklist
63111

README.md

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22

33
Free interactive course on Pydantic v2.
44

5-
It is designed to teach you the capabilities of pydantic - a validation library for python. It's (hopefully) beginner friendly, while still being useful to those already familiar with the library.
6-
7-
8-
5+
It is designed to teach you the capabilities of pydantic - a validation library for python. It's (hopefully) beginner
6+
friendly, while still being useful to those already familiar with the library.
97

108
# What is wrong with the official documentation? (note: last section)
119

12-
Absolutely nothing, this courae is just another way to learn the library, that might fit better with different people's learning styles.
10+
Absolutely nothing, this courae is just another way to learn the library, that might fit better with different people's
11+
learning styles.
1312

14-
Pydantic's docs are great at showing you how to do things and what functionality exists in the API, but they leave the "why does this feature exist and what are its use cases" for you to figure out on your own. It's not a flaw, you don't want to flood your docs with stuff like this. But it does open an another way of learning pydantic - through tutorials and courses, where technical information can be presented in a different, more friendly way.
13+
Pydantic's docs are great at showing you how to do things and what functionality exists in the API, but they leave the "
14+
why does this feature exist and what are its use cases" for you to figure out on your own. It's not a flaw, you don't
15+
want to flood your docs with stuff like this. But it does open an another way of learning pydantic - through tutorials
16+
and courses, where technical information can be presented in a different, more friendly way.
1517

16-
Pydantic is basically industry standard now, but a lot of people only use the basic basemodel difinitions, some simple validators and nothing more. Even while working on this very project i realised that pydantic has already solved a lot of things that i have been reinventing for years.
18+
Pydantic is basically industry standard now, but a lot of people only use the basic basemodel difinitions, some simple
19+
validators and nothing more. Even while working on this very project i realised that pydantic has already solved a lot
20+
of things that i have been reinventing for years.
1721

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""add cascade delete to lesson_progress
2+
3+
Revision ID: 0b7e7f6f2a4d
4+
Revises: 642e01a7aceb
5+
Create Date: 2026-03-10 21:35:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "0b7e7f6f2a4d"
16+
down_revision: Union[str, Sequence[str], None] = "642e01a7aceb"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def _find_lesson_progress_lesson_fk_name() -> str:
22+
bind = op.get_bind()
23+
inspector = sa.inspect(bind)
24+
25+
for foreign_key in inspector.get_foreign_keys("lesson_progress"):
26+
constrained_columns = foreign_key.get("constrained_columns") or []
27+
referred_table = foreign_key.get("referred_table")
28+
29+
if constrained_columns == ["lesson_id"] and referred_table == "lessons":
30+
name = foreign_key.get("name")
31+
if not name:
32+
raise RuntimeError("lesson_progress.lesson_id foreign key exists without a name")
33+
return name
34+
35+
raise RuntimeError("Could not find lesson_progress.lesson_id foreign key")
36+
37+
38+
def upgrade() -> None:
39+
"""Upgrade schema."""
40+
foreign_key_name = _find_lesson_progress_lesson_fk_name()
41+
42+
op.drop_constraint(foreign_key_name, "lesson_progress", type_="foreignkey")
43+
op.create_foreign_key(
44+
"fk_lesson_progress_lesson_id_lessons",
45+
"lesson_progress",
46+
"lessons",
47+
["lesson_id"],
48+
["id"],
49+
ondelete="CASCADE",
50+
)
51+
52+
53+
def downgrade() -> None:
54+
"""Downgrade schema."""
55+
op.drop_constraint(
56+
"fk_lesson_progress_lesson_id_lessons",
57+
"lesson_progress",
58+
type_="foreignkey",
59+
)
60+
op.create_foreign_key(
61+
"fk_lesson_progress_lesson_id_lessons",
62+
"lesson_progress",
63+
"lessons",
64+
["lesson_id"],
65+
["id"],
66+
)

backend/migrations/versions/e558abd4298f_align_lessons_schema_with_cases_column.py renamed to backend/migrations/versions/642e01a7aceb_hierarchy.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
"""align lessons schema with cases column
1+
"""hierarchy
22
3-
Revision ID: e558abd4298f
3+
Revision ID: 642e01a7aceb
44
Revises:
5-
Create Date: 2026-03-09 20:09:58.137245
5+
Create Date: 2026-03-10 19:10:23.746018
66
77
"""
88
from typing import Sequence, Union
@@ -11,7 +11,7 @@
1111
from alembic import op
1212

1313
# revision identifiers, used by Alembic.
14-
revision: str = 'e558abd4298f'
14+
revision: str = '642e01a7aceb'
1515
down_revision: Union[str, Sequence[str], None] = None
1616
branch_labels: Union[str, Sequence[str], None] = None
1717
depends_on: Union[str, Sequence[str], None] = None
@@ -22,7 +22,7 @@ def upgrade() -> None:
2222
# ### commands auto generated by Alembic - please adjust! ###
2323
op.create_table(
2424
'lessons',
25-
sa.Column('order', sa.Integer(), nullable=False),
25+
sa.Column('order', sa.String(length=64), nullable=False),
2626
sa.Column('slug', sa.String(length=255), nullable=False),
2727
sa.Column('name', sa.String(length=255), nullable=False),
2828
sa.Column('body_markdown', sa.Text(), nullable=False),
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""add no_code flag to lessons
2+
3+
Revision ID: 8c7e3c1b5e21
4+
Revises: 0b7e7f6f2a4d
5+
Create Date: 2026-03-10 22:35:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "8c7e3c1b5e21"
16+
down_revision: Union[str, Sequence[str], None] = "0b7e7f6f2a4d"
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
op.add_column(
24+
"lessons",
25+
sa.Column(
26+
"no_code",
27+
sa.Boolean(),
28+
nullable=False,
29+
server_default=sa.false(),
30+
),
31+
)
32+
33+
34+
def downgrade() -> None:
35+
"""Downgrade schema."""
36+
op.drop_column("lessons", "no_code")

backend/src/app/content/loader.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
LoadedLesson,
1010
)
1111
from src.app.content.validator import LessonsContentValidator
12+
from src.app.domain.lesson_order import lesson_order_key
1213

1314
LESSON_META_FILENAME = "lesson.yaml"
1415
LESSON_THEORY_FILENAME = "theory.md"
@@ -47,13 +48,14 @@ def load(self) -> list[LoadedLesson]:
4748
for index_item in index_file.lessons:
4849
lesson_dir = self.root_dir / index_item.slug
4950
lesson_meta = self._load_meta(lesson_dir=lesson_dir)
50-
lesson_cases = self._load_cases(lesson_dir=lesson_dir)
51+
lesson_cases = self._load_cases(lesson_dir=lesson_dir, no_code=index_item.no_code)
5152
theory = self._read_text(path=lesson_dir / LESSON_THEORY_FILENAME)
5253
starter_code = self._read_text(path=lesson_dir / LESSON_STARTER_FILENAME)
5354
lessons.append(
5455
LoadedLesson(
5556
slug=index_item.slug,
5657
order=index_item.order,
58+
no_code=index_item.no_code,
5759
name=lesson_meta.title,
5860
body_markdown=theory,
5961
code_editor_default=starter_code,
@@ -62,7 +64,7 @@ def load(self) -> list[LoadedLesson]:
6264
),
6365
)
6466

65-
lessons.sort(key=lambda item: item.order)
67+
lessons.sort(key=lambda item: lesson_order_key(item.order))
6668

6769
return lessons
6870

@@ -78,15 +80,24 @@ def _load_meta(self, lesson_dir: Path) -> LessonMetaFile:
7880

7981
return LessonMetaFile.model_validate(obj=payload)
8082

81-
def _load_cases(self, lesson_dir: Path) -> LessonCasesFile:
83+
def _load_cases(self, lesson_dir: Path, *, no_code: bool) -> LessonCasesFile:
8284
"""
8385
Load lesson cases file.
8486
8587
:param lesson_dir: lesson directory path
88+
:param no_code: lesson has no coding task
8689
8790
:return: lesson cases
8891
"""
89-
payload = self._read_yaml(path=lesson_dir / LESSON_CASES_FILENAME)
92+
cases_path = lesson_dir / LESSON_CASES_FILENAME
93+
94+
payload = self._read_yaml(path=cases_path)
95+
96+
if no_code:
97+
raw_cases = payload.get("cases")
98+
99+
if raw_cases is None:
100+
return LessonCasesFile(cases=[])
90101

91102
return LessonCasesFile.model_validate(obj=payload)
92103

backend/src/app/content/models.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44

55
from pydantic import ConfigDict, field_validator, model_validator
66

7+
from src.app.domain.lesson_order import normalize_lesson_order
78
from src.app.domain.models.dto.extended_basemodel import ExtendedBaseModel
89

910

1011
class LessonIndexItem(ExtendedBaseModel):
1112
model_config = ConfigDict(extra="forbid")
1213

1314
slug: str
14-
order: int
15+
order: str
16+
no_code: bool = False
1517

1618
@field_validator("slug")
1719
@classmethod
@@ -23,14 +25,10 @@ def validate_slug(cls, value: str) -> str:
2325

2426
return normalized
2527

26-
@field_validator("order")
28+
@field_validator("order", mode="before")
2729
@classmethod
28-
def validate_order(cls, value: int) -> int:
29-
if value < 1:
30-
message = "order must be positive."
31-
raise ValueError(message)
32-
33-
return value
30+
def validate_order(cls, value: str | int | float) -> str:
31+
return normalize_lesson_order(value=value)
3432

3533

3634
class LessonsIndexFile(ExtendedBaseModel):
@@ -104,21 +102,18 @@ class LoadedLesson(ExtendedBaseModel):
104102
model_config = ConfigDict(extra="forbid")
105103

106104
slug: str
107-
order: int
105+
order: str
106+
no_code: bool = False
108107
name: str
109108
body_markdown: str
110109
code_editor_default: str
111110
cases: list[LessonCaseFileItem]
112111
source_dir: Path
113112

114-
@field_validator("order")
113+
@field_validator("order", mode="before")
115114
@classmethod
116-
def validate_order(cls, value: int) -> int:
117-
if value < 1:
118-
message = "order must be positive."
119-
raise ValueError(message)
120-
121-
return value
115+
def validate_order(cls, value: str | int | float) -> str:
116+
return normalize_lesson_order(value=value)
122117

123118
@field_validator("slug", "name", "body_markdown")
124119
@classmethod

backend/src/app/content/validator.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from src.app.content.models import LessonsIndexFile
2+
from src.app.domain.lesson_order import lesson_order_key
23

34

45
class LessonsContentValidator:
@@ -24,12 +25,10 @@ def validate_index(index_file: LessonsIndexFile) -> None:
2425

2526
seen_slugs.add(item.slug)
2627

27-
if item.order < 1:
28-
message = f"lesson order must be positive: {item.slug}"
29-
raise ValueError(message)
30-
3128
if item.order in seen_orders:
3229
message = f"duplicate lesson order in index: {item.order}"
3330
raise ValueError(message)
3431

3532
seen_orders.add(item.order)
33+
34+
_ = sorted(lesson_order_key(item.order) for item in index_file.lessons)

backend/src/app/core/exceptions/lesson_exc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ class LessonOrderInvalid(HTTPException):
2525
Lesson order is invalid.
2626
"""
2727
status_code = 422
28-
detail = "Lesson order must be sequential with no gaps."
28+
detail = "Lesson order must be unique and use a dotted path like 1 or 2.3."
2929

3030
def __init__(self) -> None:
3131
"""

0 commit comments

Comments
 (0)