feat(backend): add service layer
This commit is contained in:
11
backend/src/services/__init__.py
Normal file
11
backend/src/services/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
24
backend/src/services/category_service.py
Normal file
24
backend/src/services/category_service.py
Normal file
@@ -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()
|
||||||
134
backend/src/services/pattern_service.py
Normal file
134
backend/src/services/pattern_service.py
Normal file
@@ -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
|
||||||
|
]
|
||||||
95
backend/src/services/question_service.py
Normal file
95
backend/src/services/question_service.py
Normal file
@@ -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()
|
||||||
55
backend/src/services/stats_service.py
Normal file
55
backend/src/services/stats_service.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user