From cccdad876809c94ee79538a050a336f176ae79fd Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Tue, 15 Apr 2025 22:57:23 +0100 Subject: [PATCH] feat(backend): add Pydantic schemas --- backend/src/schemas/__init__.py | 64 ++++++++ backend/src/schemas/category.py | 26 ++++ backend/src/schemas/common.py | 18 +++ backend/src/schemas/pattern.py | 126 ++++++++++++++++ backend/src/schemas/question.py | 249 ++++++++++++++++++++++++++++++++ backend/src/schemas/stats.py | 34 +++++ 6 files changed, 517 insertions(+) create mode 100644 backend/src/schemas/__init__.py create mode 100644 backend/src/schemas/category.py create mode 100644 backend/src/schemas/common.py create mode 100644 backend/src/schemas/pattern.py create mode 100644 backend/src/schemas/question.py create mode 100644 backend/src/schemas/stats.py diff --git a/backend/src/schemas/__init__.py b/backend/src/schemas/__init__.py new file mode 100644 index 0000000..3c43f3b --- /dev/null +++ b/backend/src/schemas/__init__.py @@ -0,0 +1,64 @@ +from src.schemas.category import CategoryListResponse, CategoryResponse +from src.schemas.common import PaginatedResponse, PaginationParams +from src.schemas.pattern import ( + CommonMistake, + LearningProgression, + LearningQuestion, + PatternDetailResponse, + PatternListResponse, + PatternResponse, + PatternTutorialResponse, + PatternVariation, + RelatedPattern, + VisualizationExample, +) +from src.schemas.question import ( + CategoryBrief, + ExplanationResponse, + HiddenTestInput, + HiddenTestOutput, + PatternBrief, + QuestionDetail, + QuestionListItem, + QuestionListResponse, + SolutionResponse, + SubmissionRequest, + SubmissionResponse, + TestResult, + VisibleTestCase, +) +from src.schemas.stats import CategoryCount, DifficultyCount, PatternCount, StatsResponse + +__all__ = [ + "CategoryBrief", + "CategoryCount", + "CategoryListResponse", + "CategoryResponse", + "CommonMistake", + "DifficultyCount", + "ExplanationResponse", + "HiddenTestInput", + "HiddenTestOutput", + "LearningProgression", + "LearningQuestion", + "PaginatedResponse", + "PaginationParams", + "PatternBrief", + "PatternCount", + "PatternDetailResponse", + "PatternListResponse", + "PatternResponse", + "PatternTutorialResponse", + "PatternVariation", + "QuestionDetail", + "QuestionListItem", + "QuestionListResponse", + "RelatedPattern", + "SolutionResponse", + "StatsResponse", + "SubmissionRequest", + "SubmissionResponse", + "TestResult", + "VisibleTestCase", + "VisualizationExample", +] diff --git a/backend/src/schemas/category.py b/backend/src/schemas/category.py new file mode 100644 index 0000000..6e967bc --- /dev/null +++ b/backend/src/schemas/category.py @@ -0,0 +1,26 @@ +from uuid import UUID + +from pydantic import BaseModel + + +class CategoryBase(BaseModel): + """Base category schema.""" + + name: str + slug: str + description: str | None = None + + +class CategoryResponse(CategoryBase): + """Category response schema.""" + + id: UUID + question_count: int = 0 + + model_config = {"from_attributes": True} + + +class CategoryListResponse(BaseModel): + """Response for category list.""" + + items: list[CategoryResponse] diff --git a/backend/src/schemas/common.py b/backend/src/schemas/common.py new file mode 100644 index 0000000..696b7d8 --- /dev/null +++ b/backend/src/schemas/common.py @@ -0,0 +1,18 @@ +from pydantic import BaseModel + + +class PaginationParams(BaseModel): + """Pagination parameters.""" + + page: int = 1 + limit: int = 20 + + +class PaginatedResponse[T](BaseModel): + """Generic paginated response.""" + + items: list[T] + total: int + page: int + limit: int + pages: int diff --git a/backend/src/schemas/pattern.py b/backend/src/schemas/pattern.py new file mode 100644 index 0000000..2457921 --- /dev/null +++ b/backend/src/schemas/pattern.py @@ -0,0 +1,126 @@ +from typing import Any +from uuid import UUID + +from pydantic import BaseModel + + +class PatternBase(BaseModel): + """Base pattern schema.""" + + name: str + slug: str + description: str | None = None + when_to_use: str | None = None + pattern_type: str | None = None + display_order: int | None = None + + +class PatternResponse(PatternBase): + """Pattern response schema.""" + + id: UUID + question_count: int = 0 + + model_config = {"from_attributes": True} + + +class PatternListResponse(BaseModel): + """Response for pattern list.""" + + items: list[PatternResponse] + + +class PatternDetailResponse(PatternBase): + """Pattern detail with related questions.""" + + id: UUID + question_count: int = 0 + + model_config = {"from_attributes": True} + + +# Tutorial-specific schemas + + +class CommonMistake(BaseModel): + """A common mistake when using a pattern.""" + + title: str + description: str + fix: str | None = None + + +class PatternVariation(BaseModel): + """A variation of a pattern.""" + + name: str + description: str + example: str | None = None + + +class RelatedPattern(BaseModel): + """Brief info about a related pattern.""" + + slug: str + name: str + description: str | None = None + + +class LearningQuestion(BaseModel): + """Question for learning progression.""" + + id: UUID + title: str + slug: str + difficulty: str + leetcode_id: int | None = None + is_optimal: bool = False + + +class LearningProgression(BaseModel): + """Curated learning path for a pattern.""" + + warmup: list[LearningQuestion] + core: list[LearningQuestion] + challenge: list[LearningQuestion] + + +class VisualizationExample(BaseModel): + """Interactive visualization example for a pattern.""" + + id: str + title: str + input: dict[str, Any] | None = None + code: str + steps: list[dict[str, Any]] + + +class PatternTutorialResponse(PatternBase): + """Full pattern tutorial with all educational content.""" + + id: UUID + question_count: int = 0 + + # Tutorial content + metaphor: str | None = None + core_concept: str | None = None + visualization: str | None = None + code_template: str | None = None + + # Structured data + recognition_signals: list[str] | None = None + common_mistakes: list[CommonMistake] | None = None + variations: list[PatternVariation] | None = None + related_patterns: list[RelatedPattern] | None = None + prerequisite_patterns: list[RelatedPattern] | None = None + + # Difficulty level (1-5) + difficulty_level: int | None = None + + # Learning progression + learning_progression: LearningProgression | None = None + + # Interactive visualization examples + visualization_examples: list[VisualizationExample] | None = None + + model_config = {"from_attributes": True} diff --git a/backend/src/schemas/question.py b/backend/src/schemas/question.py new file mode 100644 index 0000000..d43b15f --- /dev/null +++ b/backend/src/schemas/question.py @@ -0,0 +1,249 @@ +from datetime import datetime +from typing import Any +from uuid import UUID + +from pydantic import BaseModel, Field, model_validator + +from src.models.question import Difficulty + + +class ExplanationResponse(BaseModel): + """Explanation schema for API responses.""" + + approach: str + intuition: str + common_pitfalls: list[dict[str, Any]] | None = None + key_takeaways: list[str] | None = None + time_complexity: str + space_complexity: str + complexity_explanation: str | None = None + pattern_comparison: str | None = None + + model_config = {"from_attributes": True} + + +class SolutionResponse(BaseModel): + """Solution schema for API responses.""" + + id: UUID + approach_name: str + code: str + language: str + is_optimal: bool + explanation: str | None = None + + model_config = {"from_attributes": True} + + +class CategoryBrief(BaseModel): + """Brief category info for question responses.""" + + id: UUID + name: str + slug: str + + model_config = {"from_attributes": True} + + +class PatternBrief(BaseModel): + """Brief pattern info for question responses.""" + + id: UUID + name: str + slug: str + is_optimal: bool = False + + model_config = {"from_attributes": True} + + +class QuestionListItem(BaseModel): + """Question summary for list views.""" + + id: UUID + title: str + slug: str + difficulty: Difficulty + leetcode_id: int | None = None + leetcode_url: str | None = None + categories: list[CategoryBrief] = [] + patterns: list[PatternBrief] = [] + created_at: datetime + + model_config = {"from_attributes": True} + + @model_validator(mode="before") + @classmethod + def transform_patterns(cls, data: Any) -> Any: + """Transform question_patterns to patterns with is_optimal flag.""" + if hasattr(data, "question_patterns"): + # SQLAlchemy model - build patterns from question_patterns + patterns = [] + for qp in data.question_patterns: + patterns.append( + { + "id": qp.pattern.id, + "name": qp.pattern.name, + "slug": qp.pattern.slug, + "is_optimal": qp.is_optimal, + } + ) + # Create a dict with all fields from data plus transformed patterns + result = { + "id": data.id, + "title": data.title, + "slug": data.slug, + "difficulty": data.difficulty, + "leetcode_id": data.leetcode_id, + "leetcode_url": data.leetcode_url, + "categories": data.categories, + "patterns": patterns, + "created_at": data.created_at, + } + return result + return data + + +class VisibleTestCase(BaseModel): + """Visible test case with full input and expected output.""" + + id: int + input: dict[str, Any] + expected: Any + + +class HiddenTestInput(BaseModel): + """Hidden test case input only (no expected value).""" + + id: int + input: dict[str, Any] + + +class QuestionDetail(BaseModel): + """Full question detail response.""" + + id: UUID + title: str + slug: str + difficulty: Difficulty + description: str + constraints: str | None = None + examples: list[dict[str, Any]] | None = None + leetcode_id: int | None = None + leetcode_url: str | None = None + function_signature: str | None = None + visible_test_cases: list[VisibleTestCase] | None = None + hidden_test_inputs: list[HiddenTestInput] | None = None + categories: list[CategoryBrief] = [] + patterns: list[PatternBrief] = [] + explanation: ExplanationResponse | None = None + solutions: list[SolutionResponse] = [] + created_at: datetime + updated_at: datetime + + model_config = {"from_attributes": True} + + @model_validator(mode="before") + @classmethod + def transform_test_cases(cls, data: Any) -> Any: + """Transform test_cases from DB format to visible/hidden split.""" + if hasattr(data, "__dict__"): + # Transform patterns from question_patterns with is_optimal + patterns = [] + if hasattr(data, "question_patterns"): + for qp in data.question_patterns: + patterns.append( + { + "id": qp.pattern.id, + "name": qp.pattern.name, + "slug": qp.pattern.slug, + "is_optimal": qp.is_optimal, + } + ) + else: + patterns = data.patterns + + # SQLAlchemy model - convert to dict + values = { + "id": data.id, + "title": data.title, + "slug": data.slug, + "difficulty": data.difficulty, + "description": data.description, + "constraints": data.constraints, + "examples": data.examples, + "leetcode_id": data.leetcode_id, + "leetcode_url": data.leetcode_url, + "function_signature": data.function_signature, + "categories": data.categories, + "patterns": patterns, + "explanation": data.explanation, + "solutions": data.solutions, + "created_at": data.created_at, + "updated_at": data.updated_at, + } + test_cases = data.test_cases + else: + values = dict(data) + test_cases = values.pop("test_cases", None) + + if test_cases: + visible = test_cases.get("visible", []) + hidden = test_cases.get("hidden", []) + + values["visible_test_cases"] = [ + {"id": i, "input": tc["input"], "expected": tc["expected"]} + for i, tc in enumerate(visible) + ] + values["hidden_test_inputs"] = [ + {"id": len(visible) + i, "input": tc["input"]} for i, tc in enumerate(hidden) + ] + else: + values["visible_test_cases"] = None + values["hidden_test_inputs"] = None + + return values + + +class QuestionListResponse(BaseModel): + """Paginated question list response.""" + + items: list[QuestionListItem] + total: int + page: int + limit: int + pages: int + + +class HiddenTestOutput(BaseModel): + """User's output for a hidden test case.""" + + test_id: int + output: Any + + +class SubmissionRequest(BaseModel): + """Request to submit a solution for validation.""" + + code: str = Field(..., min_length=1) + hidden_outputs: list[HiddenTestOutput] + + +class TestResult(BaseModel): + """Result of running a single test case.""" + + test_id: int + passed: bool + input: dict[str, Any] | None = None + expected: Any | None = None + actual: Any + error: str | None = None + + +class SubmissionResponse(BaseModel): + """Response from submitting a solution.""" + + passed: bool + visible_results: list[TestResult] + hidden_results: list[TestResult] + total_passed: int + total_tests: int diff --git a/backend/src/schemas/stats.py b/backend/src/schemas/stats.py new file mode 100644 index 0000000..6d09382 --- /dev/null +++ b/backend/src/schemas/stats.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel + + +class DifficultyCount(BaseModel): + """Count of questions by difficulty.""" + + easy: int + medium: int + hard: int + + +class CategoryCount(BaseModel): + """Question count for a category.""" + + name: str + slug: str + count: int + + +class PatternCount(BaseModel): + """Question count for a pattern.""" + + name: str + slug: str + count: int + + +class StatsResponse(BaseModel): + """Overall statistics response.""" + + total_questions: int + by_difficulty: DifficultyCount + by_category: list[CategoryCount] + by_pattern: list[PatternCount]