Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 92 additions & 19 deletions backend/app/scripts/migrate_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@
from app.database import SessionLocal, engine
from app.models import Base, Card

Base.metadata.create_all(bind=engine)


def parse_cards(text: str) -> list[dict]:
cards = []
Expand Down Expand Up @@ -111,6 +109,92 @@ def flush_card():
return cards


def find_unclosed_code(text: str) -> list[str]:
"""Ищет незакрытые блоки кода и вопросы, потерянные из-за них.
Это эвристика: ограда с языком внутри блока считается началом нового блока.
"""
warnings: list[str] = []
category = "General"
question = None
in_code = False
code_owner: tuple[str | None, str] = (None, category)
lost: list[str] = []

def report_unclosed() -> None:
q, c = code_owner
warnings.append(
f'Незакрытый блок кода в вопросе "{q or "?"}" (категория "{c}"): '
"проверьте карточку"
)
warnings.extend(
f'Заголовок "{title}" внутри незакрытого блока: карточка не будет создана'
for title in lost
)

for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("```"):
if not in_code:
in_code = True
code_owner = (question, category)
elif len(stripped) > 3:
report_unclosed()
code_owner = (question, category)
else:
in_code = False
lost = []
continue

header = re.match(r"^(#{1,6})\s+(.+)$", line)
if in_code:
if header and len(header.group(1)) >= 3:
lost.append(header.group(2).strip())
continue

if header:
title = header.group(2).strip()
if len(header.group(1)) <= 2:
category = title
question = None
else:
question = title

if in_code:
report_unclosed()
return warnings


def import_cards(db, cards_data: list[dict]) -> tuple[int, int]:
"""Добавляет карточки в БД, пропуская дубли по паре (вопрос, категория).
Возвращает (добавлено, пропущено как дубли).
Карточки с пустым вопросом или ответом не считаются ни добавленными, ни дублями.
"""
seen = set(db.query(Card.question, Card.category).all())
inserted = 0
skipped = 0
for data in cards_data:
if not data["question"] or not data["answer"]:
continue
key = (data["question"], data["category"])
if key in seen:
skipped += 1
continue
seen.add(key)
db.add(
Card(
question=data["question"],
answer=data["answer"],
code_example=data["code_example"],
category=data["category"],
tags=json.dumps(data["tags"], ensure_ascii=False),
difficulty=data["difficulty"],
)
)
inserted += 1
db.commit()
return inserted, skipped


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--file", required=True, help="Path to questions.md")
Expand All @@ -122,33 +206,22 @@ def main():
with open(args.file, encoding="utf-8") as f:
text = f.read()

for warning in find_unclosed_code(text):
print(f"ВНИМАНИЕ: {warning}")

cards_data = parse_cards(text)
print(f"Найдено карточек: {len(cards_data)}")

Base.metadata.create_all(bind=engine)
db = SessionLocal()
try:
if args.clear:
db.query(Card).delete()
db.commit()
print("Старые карточки удалены")

inserted = 0
for data in cards_data:
if not data["question"] or not data["answer"]:
continue
card = Card(
question=data["question"],
answer=data["answer"],
code_example=data["code_example"],
category=data["category"],
tags=json.dumps(data["tags"], ensure_ascii=False),
difficulty=data["difficulty"],
)
db.add(card)
inserted += 1

db.commit()
print(f"Импортировано: {inserted} карточек")
inserted, skipped = import_cards(db, cards_data)
print(f"Импортировано: {inserted} карточек, пропущено дублей: {skipped}")
finally:
db.close()

Expand Down
12 changes: 12 additions & 0 deletions backend/tests/test_cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,15 @@ def test_tags_filter_treats_underscore_literally(self, client, db):
assert data["total"] == 1
assert data["items"][0]["question"] == "A"

def test_per_page_zero_rejected(self, client, db):
assert client.get("/api/cards?per_page=0").status_code == 422

def test_per_page_above_limit_rejected(self, client, db):
assert client.get("/api/cards?per_page=501").status_code == 422

def test_per_page_at_limit_accepted(self, client, db):
assert client.get("/api/cards?per_page=500").status_code == 200


class TestGetCardById:
def test_returns_card(self, client, db):
Expand All @@ -159,6 +168,9 @@ def test_correct_id_returned(self, client, db):
data = client.get(f"/api/cards/{card.id}").json()
assert data["id"] == card.id

def test_unknown_id_returns_404(self, client, db):
assert client.get("/api/cards/999").status_code == 404


class TestGetCategories:
def test_returns_categories(self, client, db):
Expand Down
179 changes: 179 additions & 0 deletions backend/tests/test_migrate_md.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import importlib
from unittest.mock import patch

from app.models import Card
from app.scripts import migrate_md
from app.scripts.migrate_md import parse_cards
from tests.conftest import make_card


def md(*lines: str) -> str:
return "\n".join(lines)


def card_data(question: str, category: str = "Python", answer: str = "Ответ.") -> dict:
return {
"question": question,
"answer": answer,
"code_example": None,
"category": category,
"tags": [],
"difficulty": "normal",
}


class TestParseCards:
def test_single_card(self):
cards = parse_cards(
md("# Python", "#### Что такое list?", "Изменяемая последовательность.")
)
assert cards == [
{
"question": "Что такое list?",
"answer": "Изменяемая последовательность.",
"code_example": None,
"category": "Python",
"tags": [],
"difficulty": "normal",
}
]

def test_default_category_when_no_header(self):
cards = parse_cards(md("### Вопрос?", "Ответ."))
assert cards[0]["category"] == "General"

def test_h2_changes_category(self):
cards = parse_cards(
md("# Python", "## Функции", "### Что такое lambda?", "Ответ.")
)
assert cards[0]["category"] == "Функции"

def test_several_cards_in_order(self):
cards = parse_cards(
md("# A", "### Q1", "a1", "### Q2", "a2", "# B", "### Q3", "a3")
)
assert [(c["question"], c["category"]) for c in cards] == [
("Q1", "A"),
("Q2", "A"),
("Q3", "B"),
]

def test_text_before_first_question_ignored(self):
cards = parse_cards(md("вступление", "# A", "### Q", "ответ"))
assert len(cards) == 1
assert cards[0]["answer"] == "ответ"

def test_single_code_block(self):
cards = parse_cards(md("### Q", "текст", "```python", "print(1)", "```"))
assert cards[0]["answer"] == "текст"
assert cards[0]["code_example"] == "print(1)"

def test_several_code_blocks_numbered(self):
cards = parse_cards(
md(
"### Q",
"текст",
"```python",
"print(1)",
"```",
"```python",
"print(2)",
"```",
)
)
assert cards[0]["code_example"] == (
"# Пример 1\n\nprint(1)\n\n# Пример 2\n\nprint(2)"
)

def test_header_inside_code_block_is_not_a_question(self):
cards = parse_cards(md("### Q", "текст", "```python", "# комментарий", "```"))
assert len(cards) == 1
assert cards[0]["code_example"] == "# комментарий"


class TestImportCards:
def test_inserts_new_cards(self, db):
result = migrate_md.import_cards(db, [card_data("Q1"), card_data("Q2")])
assert result == (2, 0)
assert db.query(Card).count() == 2

def test_second_run_skips_duplicates(self, db):
data = [card_data("Q1"), card_data("Q2")]
migrate_md.import_cards(db, data)
assert migrate_md.import_cards(db, data) == (0, 2)
assert db.query(Card).count() == 2

def test_existing_card_in_db_skipped(self, db):
make_card(db, question="Q", category="Python")
assert migrate_md.import_cards(db, [card_data("Q")]) == (0, 1)
assert db.query(Card).count() == 1

def test_same_question_in_other_category_is_not_duplicate(self, db):
data = [card_data("Q", "Python"), card_data("Q", "Django")]
assert migrate_md.import_cards(db, data) == (2, 0)

def test_duplicates_inside_file_skipped(self, db):
assert migrate_md.import_cards(db, [card_data("Q"), card_data("Q")]) == (1, 1)
assert db.query(Card).count() == 1

def test_empty_question_or_answer_not_imported(self, db):
data = [card_data(""), card_data("Q", answer="")]
assert migrate_md.import_cards(db, data) == (0, 0)
assert db.query(Card).count() == 0


class TestFindUnclosedCode:
def test_closed_blocks_give_no_warnings(self):
text = md(
"# Python",
"### Q",
"```python",
"print(1)",
"```",
"```python",
"print(2)",
"```",
)
assert migrate_md.find_unclosed_code(text) == []

def test_header_inside_closed_block_is_not_a_problem(self):
text = md("### Q", "```python", "# комментарий", "```")
assert migrate_md.find_unclosed_code(text) == []

def test_unclosed_at_end_of_file(self):
text = md("# Python", "### Что такое lambda?", "текст", "```python", "x = 1")
warnings = migrate_md.find_unclosed_code(text)
assert len(warnings) == 1
assert "Что такое lambda?" in warnings[0]
assert "Python" in warnings[0]

def test_unclosed_in_middle_reports_question_and_lost_header(self):
text = md(
"# Python",
"### Q1",
"```python",
"x = 1",
"### Q2",
"ответ",
"```python",
"y = 2",
"```",
)
warnings = migrate_md.find_unclosed_code(text)
assert len(warnings) == 2
assert "Q1" in warnings[0]
assert "Q2" in warnings[1]

def test_unclosed_at_end_reports_lost_header_too(self):
text = md("### Q1", "```python", "x = 1", "### Q2", "текст")
warnings = migrate_md.find_unclosed_code(text)
assert len(warnings) == 2
assert "Q1" in warnings[0]
assert "Q2" in warnings[1]


class TestImportHasNoSideEffects:
def test_import_does_not_create_tables(self):
with patch("app.models.Base.metadata.create_all") as create_all:
importlib.reload(migrate_md)
create_all.assert_not_called()
7 changes: 2 additions & 5 deletions bot/cards.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import json
import logging
import random
from html import escape

from sqlalchemy import func
from sqlalchemy.orm import Session

from config import settings
Expand Down Expand Up @@ -47,10 +47,7 @@ def get_next_cards(db: Session, count: int, last_id: int = 0) -> list[Card]:


def get_random_card(db: Session) -> Card | None:
cards = db.query(Card).all()
if not cards:
return None
return random.choice(cards)
return db.query(Card).order_by(func.random()).first()


def format_card(card: Card) -> list[str]:
Expand Down
8 changes: 0 additions & 8 deletions bot/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,3 @@
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()


def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
3 changes: 3 additions & 0 deletions bot/pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[pytest]
pythonpath = .
testpaths = tests
Loading
Loading