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
8 changes: 8 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ jobs:
working-directory: backend
run: uv sync

- name: Lint (ruff)
working-directory: backend
run: uv run ruff check && uv run ruff format --check

- name: Run tests
working-directory: backend
env:
Expand All @@ -41,6 +45,10 @@ jobs:
working-directory: bot
run: uv sync

- name: Lint (ruff)
working-directory: bot
run: uv run ruff check && uv run ruff format --check

- name: Run tests
working-directory: bot
env:
Expand Down
20 changes: 14 additions & 6 deletions backend/app/auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta

import jwt
from fastapi import Depends, HTTPException, status
Expand All @@ -10,16 +10,24 @@


def create_token() -> str:
expire = datetime.now(timezone.utc) + timedelta(hours=settings.token_expire_hours)
return jwt.encode({"sub": "admin", "exp": expire}, settings.secret_key, algorithm="HS256")
expire = datetime.now(UTC) + timedelta(hours=settings.token_expire_hours)
return jwt.encode(
{"sub": "admin", "exp": expire}, settings.secret_key, algorithm="HS256"
)


def verify_token(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)):
try:
payload = jwt.decode(credentials.credentials, settings.secret_key, algorithms=["HS256"])
payload = jwt.decode(
credentials.credentials, settings.secret_key, algorithms=["HS256"]
)
if payload.get("sub") != "admin":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired"
) from None
except jwt.InvalidTokenError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token"
) from None
1 change: 1 addition & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ class Settings(BaseSettings):
# env_file = "../.env"
# extra = "ignore"


settings = Settings()
9 changes: 5 additions & 4 deletions backend/app/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import json
from datetime import datetime, timezone
from datetime import UTC, datetime

from sqlalchemy import Column, DateTime, Integer, String, Text
from sqlalchemy.orm import validates
Expand All @@ -18,9 +18,10 @@ class Card(Base):
# хранит список тегов как JSON строку: ["list", "dict"]
tags = Column(Text, default="[]")
difficulty = Column(String(10), nullable=False, default="normal")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
created_at = Column(DateTime, default=lambda: datetime.now(UTC))
updated_at = Column(
DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)

@validates("difficulty")
def validate_difficulty(self, key, value):
Expand Down
22 changes: 18 additions & 4 deletions backend/app/routers/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@
from app.config import settings
from app.database import get_db
from app.models import Card
from app.schemas import CardCreate, CardResponse, CardUpdate, LoginRequest, TokenResponse
from app.schemas import (
CardCreate,
CardResponse,
CardUpdate,
LoginRequest,
TokenResponse,
)

router = APIRouter(prefix="/api/admin", tags=["admin"])

Expand All @@ -21,11 +27,15 @@ def login(body: LoginRequest):
body.password.encode(), settings.admin_password.encode()
)
if not password_ok:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong password")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Wrong password"
)
return TokenResponse(access_token=create_token())


@router.post("/cards", response_model=CardResponse, dependencies=[Depends(verify_token)])
@router.post(
"/cards", response_model=CardResponse, dependencies=[Depends(verify_token)]
)
def create_card(body: CardCreate, db: Session = Depends(get_db)):
card = Card(
question=body.question,
Expand All @@ -41,7 +51,11 @@ def create_card(body: CardCreate, db: Session = Depends(get_db)):
return card


@router.put("/cards/{card_id}", response_model=CardResponse, dependencies=[Depends(verify_token)])
@router.put(
"/cards/{card_id}",
response_model=CardResponse,
dependencies=[Depends(verify_token)],
)
def update_card(card_id: int, body: CardUpdate, db: Session = Depends(get_db)):
card = db.query(Card).filter(Card.id == card_id).first()
if not card:
Expand Down
14 changes: 6 additions & 8 deletions backend/app/routers/cards.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import json
from typing import Literal, Optional
from typing import Literal

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import or_
Expand All @@ -14,10 +14,10 @@

@router.get("", response_model=CardListResponse)
def get_cards(
search: Optional[str] = Query(None),
category: Optional[str] = Query(None),
difficulty: Optional[Literal["easy", "normal", "hard"]] = Query(None),
tags: Optional[str] = Query(None), # через запятую: "list,dict"
search: str | None = Query(None),
category: str | None = Query(None),
difficulty: Literal["easy", "normal", "hard"] | None = Query(None),
tags: str | None = Query(None), # через запятую: "list,dict"
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=500),
db: Session = Depends(get_db),
Expand All @@ -26,9 +26,7 @@ def get_cards(

if search:
term = f"%{search}%"
query = query.filter(
or_(Card.question.ilike(term), Card.answer.ilike(term))
)
query = query.filter(or_(Card.question.ilike(term), Card.answer.ilike(term)))

if category:
query = query.filter(Card.category == category)
Expand Down
16 changes: 8 additions & 8 deletions backend/app/schemas.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import json
from datetime import datetime
from typing import Literal, Optional
from typing import Literal

from pydantic import BaseModel, field_validator


class CardBase(BaseModel):
question: str
answer: str
code_example: Optional[str] = None
code_example: str | None = None
category: str
tags: list[str] = []
difficulty: Literal["easy", "normal", "hard"] = "normal"
Expand All @@ -19,12 +19,12 @@ class CardCreate(CardBase):


class CardUpdate(BaseModel):
question: Optional[str] = None
answer: Optional[str] = None
code_example: Optional[str] = None
category: Optional[str] = None
tags: Optional[list[str]] = None
difficulty: Optional[Literal["easy", "normal", "hard"]] = None
question: str | None = None
answer: str | None = None
code_example: str | None = None
category: str | None = None
tags: list[str] | None = None
difficulty: Literal["easy", "normal", "hard"] | None = None

# Optional здесь означает - поле можно не передавать.
# Для code_example null оставлен допустимым.
Expand Down
23 changes: 14 additions & 9 deletions backend/app/scripts/migrate_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
код пример
```
"""

import argparse
import json
import os
Expand Down Expand Up @@ -53,14 +54,16 @@ def flush_card():
else:
code = None

cards.append({
"question": current_question.strip(),
"answer": answer,
"code_example": code,
"category": current_category,
"tags": [],
"difficulty": "normal",
})
cards.append(
{
"question": current_question.strip(),
"answer": answer,
"code_example": code,
"category": current_category,
"tags": [],
"difficulty": "normal",
}
)

for line in lines:
# отслеживает блоки кода
Expand Down Expand Up @@ -111,7 +114,9 @@ def flush_card():
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--file", required=True, help="Path to questions.md")
parser.add_argument("--clear", action="store_true", help="Удалить все карточки перед импортом")
parser.add_argument(
"--clear", action="store_true", help="Удалить все карточки перед импортом"
)
args = parser.parse_args()

with open(args.file, encoding="utf-8") as f:
Expand Down
13 changes: 12 additions & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,15 @@ dev = [
"pytest>=8.0.0",
"httpx>=0.27.0",
"pytest-env>=1.1.0",
]
"ruff>=0.16.10",
]

[tool.ruff]
# Markdown файлы со скриптов миграции содержат контент карточек, форматировать нельзя
extend-exclude = ["*.md"]

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "I", "UP", "B"]

[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["fastapi.Depends"]
1 change: 1 addition & 0 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json

import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
Expand Down
4 changes: 1 addition & 3 deletions backend/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,7 @@ def test_unknown_id_returns_404(self, client):
)
def test_null_for_required_field_rejected(self, client, db, field):
card = make_card(db)
response = client.put(
f"{URL}/{card.id}", json={field: None}, headers=auth()
)
response = client.put(f"{URL}/{card.id}", json={field: None}, headers=auth())
assert response.status_code == 422
# карточка не испортилась и по-прежнему читается
assert client.get(f"/api/cards/{card.id}").status_code == 200
Expand Down
4 changes: 2 additions & 2 deletions backend/tests/test_auth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime, timedelta, timezone
from datetime import UTC, datetime, timedelta

import jwt

Expand All @@ -13,7 +13,7 @@ def auth_headers(token: str) -> dict:


def make_jwt(sub="admin", key=None, expires_in=timedelta(hours=1)) -> str:
payload = {"sub": sub, "exp": datetime.now(timezone.utc) + expires_in}
payload = {"sub": sub, "exp": datetime.now(UTC) + expires_in}
return jwt.encode(payload, key or settings.secret_key, algorithm="HS256")


Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_cards.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import json
import pytest

from tests.conftest import make_card


Expand Down
27 changes: 27 additions & 0 deletions backend/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading