feat(backend): add service layer

This commit is contained in:
2025-04-20 16:56:56 +01:00
parent cccdad8768
commit 999296006c
5 changed files with 319 additions and 0 deletions

View 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",
]

View 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()

View 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
]

View 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()

View 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,
)