diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py new file mode 100644 index 0000000..3a6226a --- /dev/null +++ b/backend/src/services/__init__.py @@ -0,0 +1,11 @@ +from src.services.category_service import CategoryService +from src.services.pattern_service import PatternService +from src.services.question_service import QuestionService +from src.services.stats_service import StatsService + +__all__ = [ + "CategoryService", + "PatternService", + "QuestionService", + "StatsService", +] diff --git a/backend/src/services/category_service.py b/backend/src/services/category_service.py new file mode 100644 index 0000000..6d14a61 --- /dev/null +++ b/backend/src/services/category_service.py @@ -0,0 +1,24 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models import Category, QuestionCategory + + +class CategoryService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def get_categories(self) -> list[tuple[Category, int]]: + query = ( + select(Category, func.count(QuestionCategory.question_id).label("question_count")) + .outerjoin(QuestionCategory, Category.id == QuestionCategory.category_id) + .group_by(Category.id) + .order_by(Category.name) + ) + result = await self.db.execute(query) + return [(row[0], row[1]) for row in result.all()] + + async def get_category_by_slug(self, slug: str) -> Category | None: + query = select(Category).where(Category.slug == slug) + result = await self.db.execute(query) + return result.scalar_one_or_none() diff --git a/backend/src/services/pattern_service.py b/backend/src/services/pattern_service.py new file mode 100644 index 0000000..f30d0ec --- /dev/null +++ b/backend/src/services/pattern_service.py @@ -0,0 +1,134 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models import Pattern, Question, QuestionPattern +from src.schemas import ( + CommonMistake, + LearningProgression, + LearningQuestion, + PatternTutorialResponse, + PatternVariation, + RelatedPattern, + VisualizationExample, +) + + +class PatternService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def get_patterns(self) -> list[tuple[Pattern, int]]: + query = ( + select(Pattern, func.count(QuestionPattern.question_id).label("question_count")) + .outerjoin(QuestionPattern, Pattern.id == QuestionPattern.pattern_id) + .group_by(Pattern.id) + .order_by(Pattern.name) + ) + result = await self.db.execute(query) + return [(row[0], row[1]) for row in result.all()] + + async def get_pattern_by_slug(self, slug: str) -> tuple[Pattern, int] | None: + query = ( + select(Pattern, func.count(QuestionPattern.question_id).label("question_count")) + .outerjoin(QuestionPattern, Pattern.id == QuestionPattern.pattern_id) + .where(Pattern.slug == slug) + .group_by(Pattern.id) + ) + result = await self.db.execute(query) + row = result.first() + if row: + return (row[0], row[1]) + return None + + async def get_pattern_tutorial(self, slug: str) -> PatternTutorialResponse | None: + result = await self.get_pattern_by_slug(slug) + if not result: + return None + + pattern, question_count = result + + questions_query = ( + select(Question, QuestionPattern.is_optimal) + .join(QuestionPattern, Question.id == QuestionPattern.question_id) + .where(QuestionPattern.pattern_id == pattern.id) + .order_by(Question.difficulty, Question.title) + ) + questions_result = await self.db.execute(questions_query) + questions_with_optimal = [(row[0], row[1]) for row in questions_result.all()] + + learning_progression = self._build_learning_progression(questions_with_optimal) + + related = await self._resolve_patterns(pattern.related_patterns or []) + prerequisites = await self._resolve_patterns(pattern.prerequisite_patterns or []) + + common_mistakes = None + if pattern.common_mistakes: + common_mistakes = [CommonMistake(**m) for m in pattern.common_mistakes] + + variations = None + if pattern.variations: + variations = [PatternVariation(**v) for v in pattern.variations] + + visualization_examples = None + if pattern.visualization_examples: + visualization_examples = [ + VisualizationExample(**e) for e in pattern.visualization_examples + ] + + return PatternTutorialResponse( + id=pattern.id, + name=pattern.name, + slug=pattern.slug, + description=pattern.description, + when_to_use=pattern.when_to_use, + question_count=question_count, + metaphor=pattern.metaphor, + core_concept=pattern.core_concept, + visualization=pattern.visualization, + code_template=pattern.code_template, + recognition_signals=pattern.recognition_signals, + common_mistakes=common_mistakes, + variations=variations, + related_patterns=related, + prerequisite_patterns=prerequisites, + difficulty_level=pattern.difficulty_level, + learning_progression=learning_progression, + visualization_examples=visualization_examples, + ) + + def _build_learning_progression( + self, questions_with_optimal: list[tuple[Question, bool]] + ) -> LearningProgression: + warmup: list[LearningQuestion] = [] + core: list[LearningQuestion] = [] + challenge: list[LearningQuestion] = [] + + for q, is_optimal in questions_with_optimal: + lq = LearningQuestion( + id=q.id, + title=q.title, + slug=q.slug, + difficulty=q.difficulty.value, + leetcode_id=q.leetcode_id, + is_optimal=is_optimal, + ) + if q.difficulty.value == "easy": + warmup.append(lq) + elif q.difficulty.value == "medium": + core.append(lq) + else: + challenge.append(lq) + + return LearningProgression(warmup=warmup, core=core, challenge=challenge) + + async def _resolve_patterns(self, slugs: list[str]) -> list[RelatedPattern]: + if not slugs: + return [] + + query = select(Pattern).where(Pattern.slug.in_(slugs)) + result = await self.db.execute(query) + patterns = result.scalars().all() + + return [ + RelatedPattern(slug=p.slug, name=p.name, description=p.description) for p in patterns + ] diff --git a/backend/src/services/question_service.py b/backend/src/services/question_service.py new file mode 100644 index 0000000..1b61deb --- /dev/null +++ b/backend/src/services/question_service.py @@ -0,0 +1,95 @@ +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from src.models import Category, Difficulty, Pattern, Question +from src.models.question import QuestionPattern + + +class QuestionService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def get_questions( + self, + *, + page: int = 1, + limit: int = 20, + difficulties: list[Difficulty] | None = None, + category_slug: str | None = None, + pattern_slug: str | None = None, + search: str | None = None, + ) -> tuple[list[Question], int]: + query = ( + select(Question) + .options( + selectinload(Question.categories), + selectinload(Question.patterns), + selectinload(Question.question_patterns).selectinload(QuestionPattern.pattern), + ) + .order_by(Question.created_at.desc()) + ) + + count_query = select(func.count(Question.id)) + + if difficulties: + query = query.where(Question.difficulty.in_(difficulties)) + count_query = count_query.where(Question.difficulty.in_(difficulties)) + + if category_slug: + query = query.join(Question.categories).where(Category.slug == category_slug) + count_query = count_query.join(Question.categories).where( + Category.slug == category_slug + ) + + if pattern_slug: + query = query.join(Question.patterns).where(Pattern.slug == pattern_slug) + count_query = count_query.join(Question.patterns).where(Pattern.slug == pattern_slug) + + if search: + search_filter = Question.title.ilike(f"%{search}%") + query = query.where(search_filter) + count_query = count_query.where(search_filter) + + total_result = await self.db.execute(count_query) + total = total_result.scalar() or 0 + + offset = (page - 1) * limit + query = query.offset(offset).limit(limit) + + result = await self.db.execute(query) + questions = list(result.scalars().unique().all()) + + return questions, total + + async def get_question_by_slug(self, slug: str) -> Question | None: + query = ( + select(Question) + .options( + selectinload(Question.categories), + selectinload(Question.patterns), + selectinload(Question.question_patterns).selectinload(QuestionPattern.pattern), + selectinload(Question.explanation), + selectinload(Question.solutions), + ) + .where(Question.slug == slug) + ) + result = await self.db.execute(query) + return result.scalar_one_or_none() + + async def get_question_by_id(self, question_id: UUID) -> Question | None: + query = ( + select(Question) + .options( + selectinload(Question.categories), + selectinload(Question.patterns), + selectinload(Question.question_patterns).selectinload(QuestionPattern.pattern), + selectinload(Question.explanation), + selectinload(Question.solutions), + ) + .where(Question.id == question_id) + ) + result = await self.db.execute(query) + return result.scalar_one_or_none() diff --git a/backend/src/services/stats_service.py b/backend/src/services/stats_service.py new file mode 100644 index 0000000..4404bc1 --- /dev/null +++ b/backend/src/services/stats_service.py @@ -0,0 +1,55 @@ +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models import Category, Difficulty, Pattern, Question, QuestionCategory, QuestionPattern +from src.schemas import CategoryCount, DifficultyCount, PatternCount, StatsResponse + + +class StatsService: + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def get_stats(self) -> StatsResponse: + total_result = await self.db.execute(select(func.count(Question.id))) + total_questions = total_result.scalar() or 0 + + difficulty_query = select( + Question.difficulty, func.count(Question.id).label("count") + ).group_by(Question.difficulty) + difficulty_result = await self.db.execute(difficulty_query) + difficulty_counts = {row[0]: row[1] for row in difficulty_result.all()} + + by_difficulty = DifficultyCount( + easy=difficulty_counts.get(Difficulty.EASY, 0), + medium=difficulty_counts.get(Difficulty.MEDIUM, 0), + hard=difficulty_counts.get(Difficulty.HARD, 0), + ) + + category_query = ( + select(Category.name, Category.slug, func.count(QuestionCategory.question_id)) + .join(QuestionCategory, Category.id == QuestionCategory.category_id) + .group_by(Category.id) + .order_by(func.count(QuestionCategory.question_id).desc()) + ) + category_result = await self.db.execute(category_query) + by_category = [ + CategoryCount(name=row[0], slug=row[1], count=row[2]) for row in category_result.all() + ] + + pattern_query = ( + select(Pattern.name, Pattern.slug, func.count(QuestionPattern.question_id)) + .join(QuestionPattern, Pattern.id == QuestionPattern.pattern_id) + .group_by(Pattern.id) + .order_by(func.count(QuestionPattern.question_id).desc()) + ) + pattern_result = await self.db.execute(pattern_query) + by_pattern = [ + PatternCount(name=row[0], slug=row[1], count=row[2]) for row in pattern_result.all() + ] + + return StatsResponse( + total_questions=total_questions, + by_difficulty=by_difficulty, + by_category=by_category, + by_pattern=by_pattern, + )