Skip to content

Commit 0d7647d

Browse files
committed
Add project workshop and execution results
1 parent 7cc466c commit 0d7647d

10 files changed

Lines changed: 422 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
6060
- Ошибки из практики автоматически попадают в отдельный мягкий режим повторения.
6161
- Код проверяется в изолированном Python-процессе с AST-фильтром и двухсекундным лимитом.
6262
- `input()` работает через безопасный симулятор: ответы вводятся под редактором, по одной строке для каждого вызова.
63+
- После сдачи урока и серии практики показывается реальный консольный вывод выполненного кода.
64+
- В разделе «Проекты» есть шесть мини-инструментов: от именной открытки до карточки контакта; они открываются постепенно и сохраняют прохождение.
6365
- Один пользователь, локальный SQLite: можно заниматься без регистрации и облака.
6466

6567
## Как устроена практика

‎app/db.py‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ def init_db() -> None:
4444
total_count INTEGER NOT NULL,
4545
passed_at TEXT NOT NULL
4646
);
47+
CREATE TABLE IF NOT EXISTS project_progress (
48+
project_id TEXT PRIMARY KEY,
49+
completed_at TEXT NOT NULL
50+
);
4751
INSERT OR IGNORE INTO profile (id, xp, streak, last_activity) VALUES (1, 0, 0, NULL);
4852
"""
4953
)
@@ -120,6 +124,23 @@ def save_exam(module_id: str, score: int, total_count: int) -> int:
120124
return gained
121125

122126

127+
def save_project(project_id: str, xp: int) -> tuple[int, bool]:
128+
"""Отмечает проект завершённым и начисляет награду только за первый проход."""
129+
with connection() as conn:
130+
_touch_activity(conn)
131+
previous = conn.execute(
132+
"SELECT project_id FROM project_progress WHERE project_id = ?", (project_id,)
133+
).fetchone()
134+
was_new = previous is None
135+
conn.execute(
136+
"INSERT OR IGNORE INTO project_progress (project_id, completed_at) VALUES (?, ?)",
137+
(project_id, datetime.now(UTC).isoformat()),
138+
)
139+
if was_new:
140+
conn.execute("UPDATE profile SET xp = xp + ? WHERE id = 1", (xp,))
141+
return (xp if was_new else 0), was_new
142+
143+
123144
def state() -> dict:
124145
with connection() as conn:
125146
profile = dict(
@@ -137,6 +158,10 @@ def state() -> dict:
137158
"SELECT module_id, score, total_count, passed_at FROM exam_progress"
138159
)
139160
}
161+
projects = {
162+
row["project_id"]: dict(row)
163+
for row in conn.execute("SELECT project_id, completed_at FROM project_progress")
164+
}
140165
weak = [
141166
row["question_id"]
142167
for row in conn.execute(
@@ -146,4 +171,10 @@ def state() -> dict:
146171
"""
147172
)
148173
]
149-
return {"profile": profile, "lessons": lessons, "exams": exams, "weak_question_ids": weak}
174+
return {
175+
"profile": profile,
176+
"lessons": lessons,
177+
"exams": exams,
178+
"projects": projects,
179+
"weak_question_ids": weak,
180+
}

‎app/main.py‎

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,18 @@
2121
public_lesson,
2222
public_question,
2323
)
24-
from app.db import DATABASE_PATH, connection, init_db, record_attempt, save_exam, save_lesson, state
24+
from app.db import (
25+
DATABASE_PATH,
26+
connection,
27+
init_db,
28+
record_attempt,
29+
save_exam,
30+
save_lesson,
31+
save_project,
32+
state,
33+
)
2534
from app.evaluator import evaluate, run_code
35+
from app.projects import PROJECT_BY_ID, PROJECTS, public_project
2636

2737
APP_DIR = Path(__file__).parent
2838
STATIC_DIR = APP_DIR / "static"
@@ -58,6 +68,14 @@ class CodeRun(CodeCheck):
5868
inputs: list[str] = []
5969

6070

71+
class ProjectSubmission(BaseModel):
72+
answer: str
73+
74+
75+
class ProjectRun(ProjectSubmission):
76+
inputs: list[str] = []
77+
78+
6179
def status_for(lesson: dict, saved: dict) -> dict:
6280
index = next(index for index, item in enumerate(LESSONS) if item["id"] == lesson["id"])
6381
completed = lesson["id"] in saved
@@ -382,6 +400,73 @@ def check_code(payload: CodeCheck) -> dict:
382400
return evaluate(question, payload.answer)
383401

384402

403+
def project_status(project: dict, index: int, snapshot: dict) -> dict:
404+
completed = project["id"] in snapshot["projects"]
405+
previous_complete = index == 0 or PROJECTS[index - 1]["id"] in snapshot["projects"]
406+
ready_by_course = len(snapshot["lessons"]) >= project["min_lessons"]
407+
return {
408+
"completed": completed,
409+
"unlocked": completed or (previous_complete and ready_by_course),
410+
}
411+
412+
413+
def project_payload(include_editor_id: str | None = None) -> list[dict]:
414+
snapshot = state()
415+
payload = []
416+
for index, project in enumerate(PROJECTS):
417+
include_editor = project["id"] == include_editor_id
418+
payload.append(
419+
{
420+
**public_project(project, include_editor=include_editor),
421+
**project_status(project, index, snapshot),
422+
}
423+
)
424+
return payload
425+
426+
427+
def require_project(project_id: str) -> dict:
428+
project = PROJECT_BY_ID.get(project_id)
429+
if not project:
430+
raise HTTPException(status_code=404, detail="Проект не найден")
431+
index = PROJECTS.index(project)
432+
if not project_status(project, index, state())["unlocked"]:
433+
raise HTTPException(
434+
status_code=403, detail="Сначала заверши предыдущий проект и нужные уроки"
435+
)
436+
return project
437+
438+
439+
@app.get("/api/projects")
440+
def get_projects() -> dict:
441+
return {"projects": project_payload()}
442+
443+
444+
@app.get("/api/projects/{project_id}")
445+
def get_project(project_id: str) -> dict:
446+
require_project(project_id)
447+
return next(item for item in project_payload(project_id) if item["id"] == project_id)
448+
449+
450+
@app.post("/api/projects/{project_id}/run")
451+
def run_project(project_id: str, payload: ProjectRun) -> dict:
452+
require_project(project_id)
453+
return run_code(payload.answer, [], payload.inputs)
454+
455+
456+
@app.post("/api/projects/{project_id}/submit")
457+
def submit_project(project_id: str, payload: ProjectSubmission) -> dict:
458+
project = require_project(project_id)
459+
result = run_code(payload.answer, project["tests"], project["test_inputs"])
460+
gained = 0
461+
if result["correct"]:
462+
gained, _ = save_project(project_id, project["xp"])
463+
return {
464+
**result,
465+
"xp_gained": gained,
466+
"message": project["success"] if result["correct"] else result["message"],
467+
}
468+
469+
385470
@app.post("/api/code/run")
386471
def run_editor_code(payload: CodeRun) -> dict:
387472
"""Запускает код с введёнными учеником строками без раскрытия тестов задания."""
@@ -435,6 +520,7 @@ def reset_progress() -> dict:
435520
conn.execute("DELETE FROM lesson_progress")
436521
conn.execute("DELETE FROM attempts")
437522
conn.execute("DELETE FROM exam_progress")
523+
conn.execute("DELETE FROM project_progress")
438524
conn.execute("UPDATE profile SET xp = 0, streak = 0, last_activity = NULL WHERE id = 1")
439525
return {"ok": True}
440526

‎app/projects.py‎

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""Небольшие практические проекты с постепенным усложнением."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
PROJECTS: list[dict[str, Any]] = [
8+
{
9+
"id": "greeting-card",
10+
"order": 1,
11+
"title": "Именная открытка",
12+
"subtitle": "Первый инструмент: программа спрашивает имя и здоровается.",
13+
"icon": "👋",
14+
"level": "Старт",
15+
"min_lessons": 0,
16+
"xp": 25,
17+
"description": "Собери короткую программу с input(), переменной и f-строкой.",
18+
"skills": ["input()", "переменные", "f-строки"],
19+
"checklist": [
20+
"Запросить имя с понятной подсказкой.",
21+
"Сохранить ответ в переменную name.",
22+
"Вывести приветствие с этим именем.",
23+
],
24+
"starter": "name = input('Имя: ')\n# напиши приветствие\n",
25+
"test_inputs": ["Лена"],
26+
"input_example": ["Лена"],
27+
"tests": [{"kind": "stdout", "expected": "Имя: Лена\nПривет, Лена!"}],
28+
"hints": [
29+
"input(...) возвращает текст — его можно сразу сохранить в name.",
30+
"Для подстановки имени используй print(f'Привет, {name}!').",
31+
],
32+
"success": "Открытка готова: программа получила данные и показала персональный ответ.",
33+
},
34+
{
35+
"id": "purchase-calculator",
36+
"order": 2,
37+
"title": "Калькулятор покупки",
38+
"subtitle": "Считаем стоимость нескольких одинаковых товаров.",
39+
"icon": "🧮",
40+
"level": "База",
41+
"min_lessons": 5,
42+
"xp": 35,
43+
"description": "Преврати текстовый ввод в числа, умножь цену на количество и покажи итог.",
44+
"skills": ["int()", "умножение", "f-строки"],
45+
"checklist": [
46+
"Получить цену и количество через input().",
47+
"Преобразовать оба ответа через int().",
48+
"Напечатать итог в формате «Итого: число».",
49+
],
50+
"starter": "price = int(input('Цена: '))\ncount = int(input('Количество: '))\n# выведи итог\n",
51+
"test_inputs": ["120", "3"],
52+
"input_example": ["120", "3"],
53+
"tests": [{"kind": "stdout", "expected": "Цена: 120\nКоличество: 3\nИтого: 360"}],
54+
"hints": [
55+
"После int(...) price и count уже числа: их можно умножать.",
56+
"Сначала вычисли price * count, затем подставь результат в f-строку.",
57+
],
58+
"success": "Калькулятор посчитал итог и понятно показал его пользователю.",
59+
},
60+
{
61+
"id": "access-checker",
62+
"order": 3,
63+
"title": "Проверка доступа",
64+
"subtitle": "Принимаем решение через if и else.",
65+
"icon": "🔐",
66+
"level": "Логика",
67+
"min_lessons": 10,
68+
"xp": 45,
69+
"description": "Сделай мини-проверку пароля: правильный открывает доступ, любой другой — нет.",
70+
"skills": ["условия", "==", "ветка else"],
71+
"checklist": [
72+
"Запросить пароль в переменную password.",
73+
"Сравнить его со строкой python.",
74+
"Показать один из двух понятных ответов.",
75+
],
76+
"starter": "password = input('Пароль: ')\n# добавь if и else\n",
77+
"test_inputs": ["python"],
78+
"input_example": ["python"],
79+
"tests": [{"kind": "stdout", "expected": "Пароль: python\nДоступ открыт"}],
80+
"hints": [
81+
"Сравнение пишется двумя знаками: password == 'python'.",
82+
"После if и else поставь двоеточие и не забудь отступ в четыре пробела.",
83+
],
84+
"success": "Проверка доступа работает: условие выбирает нужный сценарий.",
85+
},
86+
{
87+
"id": "word-counter",
88+
"order": 4,
89+
"title": "Счётчик слов",
90+
"subtitle": "Разбираем текст и получаем полезную цифру.",
91+
"icon": "📝",
92+
"level": "Строки",
93+
"min_lessons": 16,
94+
"xp": 55,
95+
"description": "Программа получает фразу, делит её на слова и сообщает количество слов.",
96+
"skills": ["split()", "списки", "len()"],
97+
"checklist": [
98+
"Получить фразу в переменную text.",
99+
"Разделить строку через text.split().",
100+
"Вывести количество слов с понятной подписью.",
101+
],
102+
"starter": "text = input('Фраза: ')\nwords = text.split()\n# выведи количество слов\n",
103+
"test_inputs": ["учу Python каждый день"],
104+
"input_example": ["учу Python каждый день"],
105+
"tests": [{"kind": "stdout", "expected": "Фраза: учу Python каждый день\nСлов: 4"}],
106+
"hints": [
107+
"words — это список слов, поэтому len(words) даст нужное число.",
108+
"Результат удобно показать так: print(f'Слов: {len(words)}').",
109+
],
110+
"success": "Счётчик превратил живой текст в полезный результат.",
111+
},
112+
{
113+
"id": "score-summary",
114+
"order": 5,
115+
"title": "Сводка оценок",
116+
"subtitle": "Считаем сумму и среднее по списку.",
117+
"icon": "📊",
118+
"level": "Коллекции",
119+
"min_lessons": 25,
120+
"xp": 65,
121+
"description": "Собери маленький отчёт: сколько оценок, какая сумма и среднее значение.",
122+
"skills": ["списки", "sum()", "len()"],
123+
"checklist": [
124+
"Оставить список scores как есть.",
125+
"Посчитать сумму и среднее через sum и len.",
126+
"Вывести среднее в формате «Среднее: число».",
127+
],
128+
"starter": "scores = [5, 4, 5, 3]\ntotal = sum(scores)\naverage = total / len(scores)\n# выведи среднее\n",
129+
"test_inputs": [],
130+
"input_example": [],
131+
"tests": [{"kind": "stdout", "expected": "Среднее: 4.25"}],
132+
"hints": [
133+
"total и len(scores) уже готовы: осталось вывести average.",
134+
"В f-строке можно подставить average прямо в фигурные скобки.",
135+
],
136+
"success": "Сводка готова: список превратился в понятную метрику.",
137+
},
138+
{
139+
"id": "contact-card",
140+
"order": 6,
141+
"title": "Карточка контакта",
142+
"subtitle": "Собираем и красиво показываем данные из словаря.",
143+
"icon": "📇",
144+
"level": "Данные",
145+
"min_lessons": 40,
146+
"xp": 75,
147+
"description": "Заполни словарь с контактными данными и собери из него аккуратную карточку.",
148+
"skills": ["словари", "ключи", "f-строки"],
149+
"checklist": [
150+
"Оставить ключи name и city в словаре contact.",
151+
"Достать значения по ключам.",
152+
"Вывести одну строку с именем и городом.",
153+
],
154+
"starter": "contact = {'name': 'Мира', 'city': 'Казань'}\n# выведи: Мира — Казань\n",
155+
"test_inputs": [],
156+
"input_example": [],
157+
"tests": [{"kind": "stdout", "expected": "Мира — Казань"}],
158+
"hints": [
159+
"Значение по ключу читается так: contact['name'].",
160+
"Собери обе части одной f-строкой: print(f\"{contact['name']} — {contact['city']}\").",
161+
],
162+
"success": "Карточка контакта готова: данные из словаря стали понятным интерфейсом.",
163+
},
164+
]
165+
166+
PROJECT_BY_ID = {project["id"]: project for project in PROJECTS}
167+
168+
169+
def public_project(project: dict[str, Any], include_editor: bool = False) -> dict[str, Any]:
170+
"""Не выдаёт тесты и контрольные входные данные в браузер."""
171+
hidden = {"tests", "test_inputs", "starter", "hints", "checklist", "input_example"}
172+
result = {key: value for key, value in project.items() if key not in hidden}
173+
if include_editor:
174+
result.update(
175+
{
176+
"starter": project["starter"],
177+
"hints": project["hints"],
178+
"checklist": project["checklist"],
179+
"input_example": project["input_example"],
180+
}
181+
)
182+
return result

0 commit comments

Comments
 (0)