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