From f328ed2d35ec8b742a7eeaeb6f63fcd232b47cf5 Mon Sep 17 00:00:00 2001 From: Max Chis Date: Sat, 4 Oct 2025 20:32:25 -0400 Subject: [PATCH] Add leaderboard and user contribution endpoints --- src/api/endpoints/contributions/__init__.py | 0 .../contributions/leaderboard/__init__.py | 0 .../contributions/leaderboard/query.py | 39 ++++++++++++ .../contributions/leaderboard/response.py | 9 +++ src/api/endpoints/contributions/routes.py | 33 ++++++++++ .../contributions/shared/__init__.py | 0 .../contributions/shared/contributions.py | 31 ++++++++++ .../endpoints/contributions/user/__init__.py | 0 .../contributions/user/queries/__init__.py | 0 .../user/queries/agreement/__init__.py | 0 .../user/queries/agreement/agency.py | 54 ++++++++++++++++ .../user/queries/agreement/record_type.py | 54 ++++++++++++++++ .../user/queries/agreement/url_type.py | 61 +++++++++++++++++++ .../user/queries/annotated_and_validated.py | 34 +++++++++++ .../contributions/user/queries/core.py | 59 ++++++++++++++++++ .../user/queries/templates/__init__.py | 0 .../user/queries/templates/agreement.py | 35 +++++++++++ .../endpoints/contributions/user/response.py | 10 +++ src/api/main.py | 4 +- tests/manual/api/test_contributions.py | 14 +++++ 20 files changed, 436 insertions(+), 1 deletion(-) create mode 100644 src/api/endpoints/contributions/__init__.py create mode 100644 src/api/endpoints/contributions/leaderboard/__init__.py create mode 100644 src/api/endpoints/contributions/leaderboard/query.py create mode 100644 src/api/endpoints/contributions/leaderboard/response.py create mode 100644 src/api/endpoints/contributions/routes.py create mode 100644 src/api/endpoints/contributions/shared/__init__.py create mode 100644 src/api/endpoints/contributions/shared/contributions.py create mode 100644 src/api/endpoints/contributions/user/__init__.py create mode 100644 src/api/endpoints/contributions/user/queries/__init__.py create mode 100644 src/api/endpoints/contributions/user/queries/agreement/__init__.py create mode 100644 src/api/endpoints/contributions/user/queries/agreement/agency.py create mode 100644 src/api/endpoints/contributions/user/queries/agreement/record_type.py create mode 100644 src/api/endpoints/contributions/user/queries/agreement/url_type.py create mode 100644 src/api/endpoints/contributions/user/queries/annotated_and_validated.py create mode 100644 src/api/endpoints/contributions/user/queries/core.py create mode 100644 src/api/endpoints/contributions/user/queries/templates/__init__.py create mode 100644 src/api/endpoints/contributions/user/queries/templates/agreement.py create mode 100644 src/api/endpoints/contributions/user/response.py create mode 100644 tests/manual/api/test_contributions.py diff --git a/src/api/endpoints/contributions/__init__.py b/src/api/endpoints/contributions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/api/endpoints/contributions/leaderboard/__init__.py b/src/api/endpoints/contributions/leaderboard/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/api/endpoints/contributions/leaderboard/query.py b/src/api/endpoints/contributions/leaderboard/query.py new file mode 100644 index 00000000..4075585f --- /dev/null +++ b/src/api/endpoints/contributions/leaderboard/query.py @@ -0,0 +1,39 @@ +from typing import Sequence + +from sqlalchemy import select, RowMapping +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.endpoints.contributions.leaderboard.response import ContributionsLeaderboardResponse, \ + ContributionsLeaderboardInnerResponse +from src.api.endpoints.contributions.shared.contributions import ContributionsCTEContainer +from src.db.helpers.session import session_helper as sh +from src.db.queries.base.builder import QueryBuilderBase + + +class GetContributionsLeaderboardQueryBuilder(QueryBuilderBase): + + async def run(self, session: AsyncSession) -> ContributionsLeaderboardResponse: + cte = ContributionsCTEContainer() + + query = ( + select( + cte.user_id, + cte.count, + ) + .order_by( + cte.count.desc() + ) + ) + + mappings: Sequence[RowMapping] = await sh.mappings(session, query=query) + inner_responses = [ + ContributionsLeaderboardInnerResponse( + user_id=mapping["user_id"], + count=mapping["count"] + ) + for mapping in mappings + ] + + return ContributionsLeaderboardResponse( + leaderboard=inner_responses + ) \ No newline at end of file diff --git a/src/api/endpoints/contributions/leaderboard/response.py b/src/api/endpoints/contributions/leaderboard/response.py new file mode 100644 index 00000000..a92c177b --- /dev/null +++ b/src/api/endpoints/contributions/leaderboard/response.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class ContributionsLeaderboardInnerResponse(BaseModel): + user_id: int + count: int + +class ContributionsLeaderboardResponse(BaseModel): + leaderboard: list[ContributionsLeaderboardInnerResponse] \ No newline at end of file diff --git a/src/api/endpoints/contributions/routes.py b/src/api/endpoints/contributions/routes.py new file mode 100644 index 00000000..b497ff6b --- /dev/null +++ b/src/api/endpoints/contributions/routes.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends + +from src.api.dependencies import get_async_core +from src.api.endpoints.contributions.leaderboard.query import GetContributionsLeaderboardQueryBuilder +from src.api.endpoints.contributions.leaderboard.response import ContributionsLeaderboardResponse +from src.api.endpoints.contributions.user.queries.core import GetUserContributionsQueryBuilder +from src.api.endpoints.contributions.user.response import ContributionsUserResponse +from src.core.core import AsyncCore +from src.security.dtos.access_info import AccessInfo +from src.security.manager import get_access_info + +contributions_router = APIRouter( + prefix="/contributions", + tags=["Contributions"], +) + +@contributions_router.get("/leaderboard") +async def get_leaderboard( + core: AsyncCore = Depends(get_async_core), + access_info: AccessInfo = Depends(get_access_info) +) -> ContributionsLeaderboardResponse: + return await core.adb_client.run_query_builder( + GetContributionsLeaderboardQueryBuilder() + ) + +@contributions_router.get("/user") +async def get_user_contributions( + core: AsyncCore = Depends(get_async_core), + access_info: AccessInfo = Depends(get_access_info) +) -> ContributionsUserResponse: + return await core.adb_client.run_query_builder( + GetUserContributionsQueryBuilder(access_info.user_id) + ) \ No newline at end of file diff --git a/src/api/endpoints/contributions/shared/__init__.py b/src/api/endpoints/contributions/shared/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/api/endpoints/contributions/shared/contributions.py b/src/api/endpoints/contributions/shared/contributions.py new file mode 100644 index 00000000..477f0365 --- /dev/null +++ b/src/api/endpoints/contributions/shared/contributions.py @@ -0,0 +1,31 @@ +from sqlalchemy import select, func, CTE, Column + +from src.db.models.impl.url.suggestion.relevant.user import UserURLTypeSuggestion + + +class ContributionsCTEContainer: + + def __init__(self): + self._cte = ( + select( + UserURLTypeSuggestion.user_id, + func.count().label("count") + ) + .group_by( + UserURLTypeSuggestion.user_id + ) + .cte("contributions") + ) + + @property + def cte(self) -> CTE: + return self._cte + + @property + def count(self) -> Column[int]: + return self.cte.c.count + + @property + def user_id(self) -> Column[int]: + return self.cte.c.user_id + diff --git a/src/api/endpoints/contributions/user/__init__.py b/src/api/endpoints/contributions/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/api/endpoints/contributions/user/queries/__init__.py b/src/api/endpoints/contributions/user/queries/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/api/endpoints/contributions/user/queries/agreement/__init__.py b/src/api/endpoints/contributions/user/queries/agreement/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/api/endpoints/contributions/user/queries/agreement/agency.py b/src/api/endpoints/contributions/user/queries/agreement/agency.py new file mode 100644 index 00000000..897373f9 --- /dev/null +++ b/src/api/endpoints/contributions/user/queries/agreement/agency.py @@ -0,0 +1,54 @@ +from sqlalchemy import select, func, exists + +from src.api.endpoints.contributions.user.queries.annotated_and_validated import AnnotatedAndValidatedCTEContainer +from src.api.endpoints.contributions.user.queries.templates.agreement import AgreementCTEContainer +from src.db.models.impl.link.url_agency.sqlalchemy import LinkURLAgency +from src.db.models.impl.url.suggestion.agency.user import UserUrlAgencySuggestion + + +def get_agency_agreement_cte_container( + inner_cte: AnnotatedAndValidatedCTEContainer +) -> AgreementCTEContainer: + + count_cte = ( + select( + inner_cte.user_id, + func.count() + ) + .join( + UserUrlAgencySuggestion, + inner_cte.user_id == UserUrlAgencySuggestion.user_id + ) + .group_by( + inner_cte.user_id + ) + .cte("agency_count_total") + ) + + agreed_cte = ( + select( + inner_cte.user_id, + func.count() + ) + .join( + UserUrlAgencySuggestion, + inner_cte.user_id == UserUrlAgencySuggestion.user_id + ) + .where( + exists() + .where( + LinkURLAgency.url_id == UserUrlAgencySuggestion.url_id, + LinkURLAgency.agency_id == UserUrlAgencySuggestion.agency_id + ) + ) + .group_by( + inner_cte.user_id + ) + .cte("agency_count_agreed") + ) + + return AgreementCTEContainer( + count_cte=count_cte, + agreed_cte=agreed_cte, + name="agency" + ) diff --git a/src/api/endpoints/contributions/user/queries/agreement/record_type.py b/src/api/endpoints/contributions/user/queries/agreement/record_type.py new file mode 100644 index 00000000..2cde5ab5 --- /dev/null +++ b/src/api/endpoints/contributions/user/queries/agreement/record_type.py @@ -0,0 +1,54 @@ +from sqlalchemy import select, func, and_ + +from src.api.endpoints.contributions.user.queries.annotated_and_validated import AnnotatedAndValidatedCTEContainer +from src.api.endpoints.contributions.user.queries.templates.agreement import AgreementCTEContainer +from src.db.models.impl.url.record_type.sqlalchemy import URLRecordType +from src.db.models.impl.url.suggestion.record_type.user import UserRecordTypeSuggestion + + +def get_record_type_agreement_cte_container( + inner_cte: AnnotatedAndValidatedCTEContainer +) -> AgreementCTEContainer: + + count_cte = ( + select( + inner_cte.user_id, + func.count() + ) + .join( + UserRecordTypeSuggestion, + UserRecordTypeSuggestion.url_id == inner_cte.url_id + ) + .group_by( + inner_cte.user_id + ) + .cte("record_type_count_total") + ) + + agreed_cte = ( + select( + inner_cte.user_id, + func.count() + ) + .join( + UserRecordTypeSuggestion, + UserRecordTypeSuggestion.url_id == inner_cte.url_id + ) + .join( + URLRecordType, + and_( + URLRecordType.url_id == inner_cte.url_id, + URLRecordType.record_type == UserRecordTypeSuggestion.record_type + ) + ) + .group_by( + inner_cte.user_id + ) + .cte("record_type_count_agreed") + ) + + return AgreementCTEContainer( + count_cte=count_cte, + agreed_cte=agreed_cte, + name="record_type" + ) \ No newline at end of file diff --git a/src/api/endpoints/contributions/user/queries/agreement/url_type.py b/src/api/endpoints/contributions/user/queries/agreement/url_type.py new file mode 100644 index 00000000..cf028bf1 --- /dev/null +++ b/src/api/endpoints/contributions/user/queries/agreement/url_type.py @@ -0,0 +1,61 @@ +from sqlalchemy import select, func, and_ + +from src.api.endpoints.contributions.user.queries.annotated_and_validated import AnnotatedAndValidatedCTEContainer +from src.api.endpoints.contributions.user.queries.templates.agreement import AgreementCTEContainer +from src.db.models.impl.flag.url_validated.sqlalchemy import FlagURLValidated +from src.db.models.impl.url.suggestion.relevant.user import UserURLTypeSuggestion + + +def get_url_type_agreement_cte_container( + inner_cte: AnnotatedAndValidatedCTEContainer +) -> AgreementCTEContainer: + + # Count CTE is number of User URL Type Suggestions + count_cte = ( + select( + inner_cte.user_id, + func.count() + ) + .join( + UserURLTypeSuggestion, + UserURLTypeSuggestion.url_id == inner_cte.url_id + ) + .join( + FlagURLValidated, + FlagURLValidated.url_id == inner_cte.url_id + ) + .group_by( + inner_cte.user_id + ) + .cte("url_type_count_total") + ) + + agreed_cte = ( + select( + inner_cte.user_id, + func.count() + ) + .join( + UserURLTypeSuggestion, + UserURLTypeSuggestion.url_id == inner_cte.url_id + ) + .join( + FlagURLValidated, + and_( + FlagURLValidated.url_id == inner_cte.url_id, + UserURLTypeSuggestion.type == FlagURLValidated.type + + ) + ) + .group_by( + inner_cte.user_id + ) + .cte("url_type_count_agreed") + ) + + return AgreementCTEContainer( + count_cte=count_cte, + agreed_cte=agreed_cte, + name="url_type" + ) + diff --git a/src/api/endpoints/contributions/user/queries/annotated_and_validated.py b/src/api/endpoints/contributions/user/queries/annotated_and_validated.py new file mode 100644 index 00000000..a9740328 --- /dev/null +++ b/src/api/endpoints/contributions/user/queries/annotated_and_validated.py @@ -0,0 +1,34 @@ +from sqlalchemy import select, Column, CTE + +from src.db.models.impl.flag.url_validated.sqlalchemy import FlagURLValidated +from src.db.models.impl.url.suggestion.relevant.user import UserURLTypeSuggestion + + +class AnnotatedAndValidatedCTEContainer: + + def __init__(self, user_id: int | None): + self._cte = ( + select( + UserURLTypeSuggestion.user_id, + UserURLTypeSuggestion.url_id + ) + .join( + FlagURLValidated, + FlagURLValidated.url_id == UserURLTypeSuggestion.url_id + ) + ) + if user_id is not None: + self._cte = self._cte.where(UserURLTypeSuggestion.user_id == user_id) + self._cte = self._cte.cte("annotated_and_validated") + + @property + def cte(self) -> CTE: + return self._cte + + @property + def url_id(self) -> Column[int]: + return self.cte.c.url_id + + @property + def user_id(self) -> Column[int]: + return self.cte.c.user_id \ No newline at end of file diff --git a/src/api/endpoints/contributions/user/queries/core.py b/src/api/endpoints/contributions/user/queries/core.py new file mode 100644 index 00000000..57727215 --- /dev/null +++ b/src/api/endpoints/contributions/user/queries/core.py @@ -0,0 +1,59 @@ +from sqlalchemy import select, RowMapping +from sqlalchemy.ext.asyncio import AsyncSession + +from src.api.endpoints.contributions.shared.contributions import ContributionsCTEContainer +from src.api.endpoints.contributions.user.queries.agreement.agency import get_agency_agreement_cte_container +from src.api.endpoints.contributions.user.queries.agreement.record_type import get_record_type_agreement_cte_container +from src.api.endpoints.contributions.user.queries.agreement.url_type import get_url_type_agreement_cte_container +from src.api.endpoints.contributions.user.queries.annotated_and_validated import AnnotatedAndValidatedCTEContainer +from src.api.endpoints.contributions.user.queries.templates.agreement import AgreementCTEContainer +from src.api.endpoints.contributions.user.response import ContributionsUserResponse, ContributionsUserAgreement +from src.db.helpers.session import session_helper as sh +from src.db.queries.base.builder import QueryBuilderBase + + +class GetUserContributionsQueryBuilder(QueryBuilderBase): + + def __init__(self, user_id: int): + super().__init__() + self.user_id = user_id + + async def run(self, session: AsyncSession) -> ContributionsUserResponse: + inner_cte = AnnotatedAndValidatedCTEContainer(self.user_id) + + contributions_cte = ContributionsCTEContainer() + record_type_agree: AgreementCTEContainer = get_record_type_agreement_cte_container(inner_cte) + agency_agree: AgreementCTEContainer = get_agency_agreement_cte_container(inner_cte) + url_type_agree: AgreementCTEContainer = get_url_type_agreement_cte_container(inner_cte) + + query = ( + select( + contributions_cte.count, + record_type_agree.agreement.label("record_type"), + agency_agree.agreement.label("agency"), + url_type_agree.agreement.label("url_type") + ) + .join( + record_type_agree.cte, + contributions_cte.user_id == record_type_agree.user_id + ) + .join( + agency_agree.cte, + contributions_cte.user_id == agency_agree.user_id + ) + .join( + url_type_agree.cte, + contributions_cte.user_id == url_type_agree.user_id + ) + ) + + mapping: RowMapping = await sh.mapping(session, query=query) + + return ContributionsUserResponse( + count_validated=mapping.count, + agreement=ContributionsUserAgreement( + record_type=mapping.record_type, + agency=mapping.agency, + url_type=mapping.url_type + ) + ) \ No newline at end of file diff --git a/src/api/endpoints/contributions/user/queries/templates/__init__.py b/src/api/endpoints/contributions/user/queries/templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/api/endpoints/contributions/user/queries/templates/agreement.py b/src/api/endpoints/contributions/user/queries/templates/agreement.py new file mode 100644 index 00000000..8479f90c --- /dev/null +++ b/src/api/endpoints/contributions/user/queries/templates/agreement.py @@ -0,0 +1,35 @@ +from sqlalchemy import CTE, select, Column + + +class AgreementCTEContainer: + + def __init__( + self, + count_cte: CTE, + agreed_cte: CTE, + name: str + ): + self._cte = ( + select( + count_cte.c.user_id, + (agreed_cte.c.count / count_cte.c.count).label("agreement") + ) + .join( + agreed_cte, + count_cte.c.user_id == agreed_cte.c.user_id + ) + .cte(f"{name}_agreement") + ) + + @property + def cte(self) -> CTE: + return self._cte + + @property + def user_id(self) -> Column[int]: + return self.cte.c.user_id + + @property + def agreement(self) -> Column[float]: + return self.cte.c.agreement + diff --git a/src/api/endpoints/contributions/user/response.py b/src/api/endpoints/contributions/user/response.py new file mode 100644 index 00000000..8151c493 --- /dev/null +++ b/src/api/endpoints/contributions/user/response.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel, Field + +class ContributionsUserAgreement(BaseModel): + record_type: float = Field(ge=0, le=1) + agency: float = Field(ge=0, le=1) + url_type: float = Field(ge=0, le=1) + +class ContributionsUserResponse(BaseModel): + count_validated: int + agreement: ContributionsUserAgreement \ No newline at end of file diff --git a/src/api/main.py b/src/api/main.py index d1097de3..2d31dc1f 100644 --- a/src/api/main.py +++ b/src/api/main.py @@ -10,6 +10,7 @@ from src.api.endpoints.annotate.routes import annotate_router from src.api.endpoints.batch.routes import batch_router from src.api.endpoints.collector.routes import collector_router +from src.api.endpoints.contributions.routes import contributions_router from src.api.endpoints.metrics.routes import metrics_router from src.api.endpoints.root import root_router from src.api.endpoints.search.routes import search_router @@ -175,7 +176,8 @@ async def redirect_docs(): task_router, search_router, metrics_router, - submit_router + submit_router, + contributions_router ] for router in routers: diff --git a/tests/manual/api/test_contributions.py b/tests/manual/api/test_contributions.py new file mode 100644 index 00000000..1d79fe33 --- /dev/null +++ b/tests/manual/api/test_contributions.py @@ -0,0 +1,14 @@ +import pytest + +from src.api.endpoints.contributions.user.queries import GetUserContributionsQueryBuilder +from src.db.client.async_ import AsyncDatabaseClient + + +@pytest.mark.asyncio +async def test_contributions( + adb_client_test: AsyncDatabaseClient +): + + await adb_client_test.run_query_builder( + GetUserContributionsQueryBuilder(user_id=72) + ) \ No newline at end of file