Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/api/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from backend.domain.services.abandoned_problems_service import AbandonedProblemsService
from backend.domain.services.difficulty_distribution_service import DifficultyDistributionService
from backend.domain.services.daily_activity_service import DailyActivityService
from backend.domain.services.division_problems_service import DivisionProblemsService
from backend.domain.services.tags_service import TagsService
from backend.infrastructure.task_queue import TaskQueue
from backend.infrastructure.redis_client import get_redis_client
Expand Down Expand Up @@ -40,6 +41,11 @@ def get_difficulty_distribution_service() -> DifficultyDistributionService:
return DifficultyDistributionService()


def get_division_problems_service() -> DivisionProblemsService:
"""Dependency provider for DivisionProblemsService."""
return DivisionProblemsService()


async def get_redis() -> Redis:
"""Dependency provider for Redis client."""
return await get_redis_client()
Expand Down Expand Up @@ -68,6 +74,9 @@ def get_request_metadata(request: Request) -> Dict[str, Any]:
difficulty_distribution_service_dependency = Provide(
get_difficulty_distribution_service, sync_to_thread=False
)
division_problems_service_dependency = Provide(
get_division_problems_service, sync_to_thread=False
)
tags_service_dependency = Provide(get_tags_service, sync_to_thread=False)
request_metadata_dependency = Provide(get_request_metadata, sync_to_thread=False)
redis_dependency = Provide(get_redis)
Expand Down
3 changes: 3 additions & 0 deletions backend/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
from backend.api.routes.base import BaseMetricController
from backend.api.routes.daily_activity import DailyActivityController
from backend.api.routes.difficulty_distribution import DifficultyDistributionController
from backend.api.routes.division_problems import DivisionProblemsController
from backend.api.routes.tags import TagsController
from backend.api.routes.tasks import TaskController

routes = [
AbandonedProblemsController,
DailyActivityController,
DifficultyDistributionController,
DivisionProblemsController,
TagsController,
TaskController,
]
Expand All @@ -20,6 +22,7 @@
"BaseMetricController",
"DailyActivityController",
"DifficultyDistributionController",
"DivisionProblemsController",
"TagsController",
"TaskController",
"routes",
Expand Down
178 changes: 178 additions & 0 deletions backend/api/routes/division_problems.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
"""Division problems API routes."""

import asyncio
from datetime import datetime, timezone
from typing import Union

from litestar import get
from litestar.params import Parameter
from litestar.response import Response
from litestar.exceptions import HTTPException
from redis.asyncio import Redis

from backend.api.deps import (
codeforces_data_service_dependency,
division_problems_service_dependency,
redis_dependency,
task_queue_dependency,
)
from backend.api.routes.base import BaseMetricController
from backend.domain.models.time_period import TimePeriod
from backend.api.schemas.division_problems import (
DivisionProblemsResponse,
DivisionStatsSchema,
)
from backend.api.schemas.common import AsyncTaskResponse
from backend.domain.services.division_problems_service import DivisionProblemsService
from backend.services.codeforces_data_service import CodeforcesDataService
from backend.infrastructure.codeforces_client import UserNotFoundError
from backend.infrastructure.task_queue import TaskQueue


class DivisionProblemsController(BaseMetricController):
"""Controller for division problems endpoints."""

path = "/division-problems"
tags = ["Division Problems"]

@get(
path="/{handle:str}",
dependencies={
"data_service": codeforces_data_service_dependency,
"division_service": division_problems_service_dependency,
"redis": redis_dependency,
"task_queue": task_queue_dependency,
},
)
async def get_division_problems(
self,
handle: str,
data_service: CodeforcesDataService,
division_service: DivisionProblemsService,
redis: Redis,
task_queue: TaskQueue,
period: TimePeriod = Parameter(
default=TimePeriod.ALL_TIME,
description="Time period to filter submissions by",
),
prefer_fresh: bool = Parameter(
default=False,
description="If true, force refresh even if stale data is available",
),
) -> Union[Response[DivisionProblemsResponse], Response[AsyncTaskResponse]]:
"""
Get user's average problems solved per division.

Returns statistics showing the average number of problems solved per contest
for each division (Div. 1, Div. 2, Div. 3, Div. 4). This helps identify
performance across different difficulty levels of contests.

Args:
handle: Codeforces handle
period: Time period to filter submissions by
prefer_fresh: Force refresh even if stale data exists

Returns:
Division problems analysis with averages per division
OR 202 Accepted with task_id if data needs to be fetched
"""
# Get contest divisions mapping
contest_divisions = await data_service.get_contest_divisions()

# Get submissions with staleness check
submissions, age, is_stale = await self.get_submissions_with_staleness(redis, handle)

# Case 1: Fresh data (< 4 hours)
if submissions and not is_stale:
submissions = self._filter_by_date_range(
submissions, start_date=period.to_start_date(now=datetime.now(timezone.utc))
)
analysis = division_service.analyze_division_problems(
handle, submissions, contest_divisions
)

divisions = [
DivisionStatsSchema.model_validate(div) for div in analysis.divisions
]

response = DivisionProblemsResponse(
divisions=divisions,
total_contests=analysis.total_contests,
total_problems_solved=analysis.total_problems_solved,
last_updated=self.get_current_timestamp(),
)

return Response(response, headers=self._cache_headers(14400 - age))

# Case 2: Stale data (4-24 hours) and !prefer_fresh
if submissions and is_stale and not prefer_fresh:
# Return stale data immediately
submissions = self._filter_by_date_range(
submissions, start_date=period.to_start_date(now=datetime.now(timezone.utc))
)
analysis = division_service.analyze_division_problems(
handle, submissions, contest_divisions
)

divisions = [
DivisionStatsSchema.model_validate(div) for div in analysis.divisions
]

response = DivisionProblemsResponse(
divisions=divisions,
total_contests=analysis.total_contests,
total_problems_solved=analysis.total_problems_solved,
last_updated=self.get_current_timestamp(),
)

# Enqueue background refresh (non-blocking)
asyncio.create_task(task_queue.enqueue(handle))

return Response(
response,
headers={
**self._cache_headers(0),
"X-Data-Stale": "true",
"X-Data-Age": str(age),
},
)

# Case 3: No data or prefer_fresh
try:
task_id = await task_queue.enqueue(handle)
return Response(
content=AsyncTaskResponse(
status="processing", task_id=task_id, retry_after=2
).model_dump(),
status_code=202,
)
except Exception:
# Fallback: try fetching directly if queue fails
try:
submissions = await data_service.get_user_submissions(handle)
except UserNotFoundError:
raise HTTPException(
status_code=404, detail=f"User '{handle}' not found on Codeforces"
)

submissions = self._filter_by_date_range(
submissions, start_date=period.to_start_date(now=datetime.now(timezone.utc))
)
self._validate_submissions_exist(submissions, handle)

analysis = division_service.analyze_division_problems(
handle, submissions, contest_divisions
)

divisions = [
DivisionStatsSchema.model_validate(div) for div in analysis.divisions
]

response = DivisionProblemsResponse(
divisions=divisions,
total_contests=analysis.total_contests,
total_problems_solved=analysis.total_problems_solved,
last_updated=self.get_current_timestamp(),
)

return Response(response, headers=self._cache_headers(14400))
36 changes: 36 additions & 0 deletions backend/api/schemas/division_problems.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Division problems API schemas."""

from datetime import datetime
from typing import List

from pydantic import Field

from backend.api.schemas.base import BaseAPISchema


class DivisionStatsSchema(BaseAPISchema):
"""Schema for statistics of a single division."""

division: str = Field(..., description="Division name (e.g., 'Div. 1', 'Div. 2')")
contest_count: int = Field(..., description="Number of contests participated in this division")
total_problems_solved: int = Field(
..., description="Total problems solved in this division"
)
average_problems_per_contest: float = Field(
..., description="Average number of problems solved per contest in this division"
)


class DivisionProblemsResponse(BaseAPISchema):
"""Response schema for division problems analysis."""

divisions: List[DivisionStatsSchema] = Field(
..., description="Statistics for each division"
)
total_contests: int = Field(
..., description="Total number of rated contests participated"
)
total_problems_solved: int = Field(
..., description="Total number of problems solved across all divisions"
)
last_updated: datetime = Field(..., description="Timestamp when data was last fetched")
3 changes: 3 additions & 0 deletions backend/domain/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
)
from .daily_activity import DailyActivity, DailyActivityAnalysis
from .difficulty_distribution import DifficultyDistribution, RatingRange
from .division_problems import DivisionProblemsAnalysis, DivisionStats
from .time_period import TimePeriod

__all__ = [
Expand All @@ -19,6 +20,8 @@
"DailyActivity",
"DailyActivityAnalysis",
"DifficultyDistribution",
"DivisionProblemsAnalysis",
"DivisionStats",
"Problem",
"RatingAbandonedStats",
"RatingRange",
Expand Down
26 changes: 26 additions & 0 deletions backend/domain/models/division_problems.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Division problems domain models."""

from dataclasses import dataclass
from typing import List

from backend.domain.models.base import BaseDomainModel


@dataclass
class DivisionStats(BaseDomainModel):
"""Statistics for a single division."""

division: str
contest_count: int
total_problems_solved: int
average_problems_per_contest: float


@dataclass
class DivisionProblemsAnalysis(BaseDomainModel):
"""Analysis of average problems solved per division."""

handle: str
divisions: List[DivisionStats]
total_contests: int
total_problems_solved: int
2 changes: 2 additions & 0 deletions backend/domain/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
from .abandoned_problems_service import AbandonedProblemsService
from .daily_activity_service import DailyActivityService
from .difficulty_distribution_service import DifficultyDistributionService
from .division_problems_service import DivisionProblemsService
from .tags_service import TagsService

__all__ = [
"AbandonedProblemsService",
"BaseMetricService",
"DailyActivityService",
"DifficultyDistributionService",
"DivisionProblemsService",
"TagsService",
]
Loading