Skip to content

Commit

Permalink
feat: added gamemode and platform filtering on all player data endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
TeKrop committed Nov 3, 2024
1 parent 7d24e14 commit 375469f
Show file tree
Hide file tree
Showing 8 changed files with 329 additions and 208 deletions.
47 changes: 46 additions & 1 deletion app/players/parsers/player_career_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,13 @@ def filter_request_using_query(self, **kwargs) -> dict:
if kwargs.get("summary"):
return self.data.get("summary")

return self._filter_stats(**kwargs) if kwargs.get("stats") else self.data
if kwargs.get("stats"):
return self._filter_stats(**kwargs)

return {
"summary": self.data["summary"],
"stats": self._filter_all_stats_data(**kwargs),
}

def _filter_stats(self, **kwargs) -> dict:
filtered_data = self.data["stats"] or {}
Expand All @@ -72,6 +78,7 @@ def _filter_stats(self, **kwargs) -> dict:
platform = possible_platforms[0]
else:
return {}

filtered_data = filtered_data.get(platform) or {}
if not filtered_data:
return {}
Expand All @@ -89,6 +96,44 @@ def _filter_stats(self, **kwargs) -> dict:
if not hero_filter or hero_filter == hero_key
}

def _filter_all_stats_data(self, **kwargs) -> dict:
stats_data = self.data["stats"] or {}
platform_filter = kwargs.get("platform")
gamemode_filter = kwargs.get("gamemode")

# Return early if no platform or gamemode is specified
if not platform_filter and not gamemode_filter:
return stats_data

# Extract platform-specific data and update stats data
# to contain only platform-specific information
if platform_filter:
stats_data = {
platform_key: (
platform_data if platform_key == platform_filter else None
)
for platform_key, platform_data in stats_data.items()
}

# Extract gamemode-specific data within the remaining platforms
# and update stats data to contain only gamemode-specific information
if gamemode_filter:
stats_data = {
platform_key: (
{
gamemode_key: (
gamemode_data if gamemode_key == gamemode_filter else None
)
for gamemode_key, gamemode_data in platform_data.items()
}
if platform_data is not None
else None
)
for platform_key, platform_data in stats_data.items()
}

return stats_data

def parse_data(self) -> dict:
# We must check if we have the expected section for profile. If not,
# it means the player doesn't exist or hasn't been found.
Expand Down
3 changes: 2 additions & 1 deletion app/players/parsers/player_search_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ def __init__(self, **kwargs):

def get_blizzard_url(self, **kwargs) -> str:
"""URL used when requesting data to Blizzard."""
return f"{super().get_blizzard_url(**kwargs)}/{kwargs.get('name')}/"
search_name = kwargs.get("name").split("#")[0]
return f"{super().get_blizzard_url(**kwargs)}/{search_name}/"

def parse_data(self) -> dict:
# Transform into PlayerSearchResult format
Expand Down
4 changes: 2 additions & 2 deletions app/players/parsers/search_data_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ def parse_data(self) -> dict:
return {self.data_type: data_value}

def get_blizzard_url(self, **kwargs) -> str:
player_battletag = kwargs.get("player_id").replace("-", "#")
return f"{super().get_blizzard_url(**kwargs)}/{player_battletag}"
player_name = kwargs.get("player_id").split("-")[0]
return f"{super().get_blizzard_url(**kwargs)}/{player_name}/"

def retrieve_data_value(self, player_data: dict) -> str | None:
# If the player doesn't have any related data, directly return nothing here
Expand Down
14 changes: 14 additions & 0 deletions app/players/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,21 @@ async def get_player_stats(
async def get_player_career(
request: Request,
commons: CommonsPlayerDep,
gamemode: PlayerGamemode = Query(
None,
title="Gamemode",
description="Filter on a specific gamemode. All gamemodes are displayed by default.",
examples=["competitive"],
),
platform: PlayerPlatform = Query(
None,
title="Platform",
description="Filter on a specific platform. All platforms are displayed by default.",
examples=["pc"],
),
) -> Player:
return await GetPlayerCareerController(request).process_request(
player_id=commons.get("player_id"),
gamemode=gamemode,
platform=platform,
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "overfast-api"
version = "3.0.0"
version = "3.1.0"
description = "Overwatch API giving data about heroes, maps, and players statistics."
license = {file = "LICENSE"}
authors = [
Expand Down
225 changes: 199 additions & 26 deletions tests/players/parsers/test_player_career_parser.py
Original file line number Diff line number Diff line change
@@ -1,58 +1,126 @@
import re
from collections.abc import Callable
from unittest.mock import Mock, patch

import pytest
from fastapi import status

from app.exceptions import ParserBlizzardError
from app.exceptions import ParserBlizzardError, ParserParsingError
from app.players.enums import PlayerGamemode, PlayerPlatform
from app.players.helpers import players_ids
from app.players.parsers.player_career_stats_parser import PlayerCareerStatsParser
from app.players.parsers.player_career_parser import PlayerCareerParser


@pytest.mark.parametrize(
("player_career_stats_parser", "player_html_data", "player_career_json_data"),
("player_career_parser", "player_html_data", "player_json_data", "kwargs_filter"),
[
(player_id, player_id, player_id)
(player_id, player_id, player_id, kwargs_filter)
for player_id in players_ids
for kwargs_filter in ({}, {"summary": True}, {"stats": True})
if player_id != "Unknown-1234"
],
indirect=[
"player_career_stats_parser",
"player_html_data",
"player_career_json_data",
],
indirect=["player_career_parser", "player_html_data", "player_json_data"],
)
@pytest.mark.asyncio
async def test_player_page_parsing_with_filters(
player_career_stats_parser: PlayerCareerStatsParser,
player_career_parser: PlayerCareerParser,
player_html_data: str,
player_career_json_data: dict,
player_json_data: dict,
kwargs_filter: dict,
player_search_response_mock: Mock,
search_data_func: Callable[[str, str], str | None],
):
with patch(
"httpx.AsyncClient.get",
side_effect=[
# Players search call first
player_search_response_mock,
# Player profile page
Mock(status_code=status.HTTP_200_OK, text=player_html_data),
],
with (
patch(
"httpx.AsyncClient.get",
side_effect=[
# Players search call first
player_search_response_mock,
# Player profile page
Mock(status_code=status.HTTP_200_OK, text=player_html_data),
],
),
patch(
"app.cache_manager.CacheManager.get_search_data_cache",
side_effect=search_data_func,
),
):
await player_career_stats_parser.parse()
await player_career_parser.parse()

# Just check that the parsing is working properly
player_career_stats_parser.filter_request_using_query()
player_career_parser.filter_request_using_query(**kwargs_filter)

assert player_career_stats_parser.data == player_career_json_data
assert player_career_parser.data == player_json_data


@pytest.mark.parametrize(
("player_career_stats_parser"),
("player_career_parser", "player_html_data", "gamemode", "platform"),
[
("TeKrop-2217", "TeKrop-2217", gamemode, platform)
for gamemode in (None, *PlayerGamemode)
for platform in (None, *PlayerPlatform)
],
indirect=["player_career_parser", "player_html_data"],
)
@pytest.mark.asyncio
async def test_filter_all_stats_data(
player_career_parser: PlayerCareerParser,
player_html_data: str,
gamemode: PlayerGamemode | None,
platform: PlayerPlatform | None,
player_search_response_mock: Mock,
search_data_func: Callable[[str, str], str | None],
):
with (
patch(
"httpx.AsyncClient.get",
side_effect=[
# Players search call first
player_search_response_mock,
# Player profile page
Mock(status_code=status.HTTP_200_OK, text=player_html_data),
],
),
patch(
"app.cache_manager.CacheManager.get_search_data_cache",
side_effect=search_data_func,
),
):
await player_career_parser.parse()

# Just check that the parsing is working properly
filtered_data = player_career_parser._filter_all_stats_data(
platform=platform, gamemode=gamemode
)

if platform:
assert all(
platform_data is None
for platform_key, platform_data in filtered_data.items()
if filtered_data is not None and platform_key != platform
)

if gamemode:
assert all(
gamemode_data is None
for platform_key, platform_data in filtered_data.items()
if platform_data is not None
for gamemode_key, gamemode_data in platform_data.items()
if gamemode_key != gamemode
)

if not platform and not gamemode:
assert filtered_data == player_career_parser.data["stats"]


@pytest.mark.parametrize(
("player_career_parser"),
[("Unknown-1234")],
indirect=True,
)
@pytest.mark.asyncio
async def test_unknown_player_parser_blizzard_error(
player_career_stats_parser: PlayerCareerStatsParser,
async def test_unknown_player_career_parser_blizzard_error(
player_career_parser: PlayerCareerParser,
player_search_response_mock: Mock,
):
with (
Expand All @@ -62,4 +130,109 @@ async def test_unknown_player_parser_blizzard_error(
return_value=player_search_response_mock,
),
):
await player_career_stats_parser.parse()
await player_career_parser.parse()


@pytest.mark.parametrize(
("player_career_parser", "player_html_data"),
[("TeKrop-2217", "TeKrop-2217")],
indirect=["player_career_parser", "player_html_data"],
)
@pytest.mark.asyncio
async def test_player_career_parser_parsing_error_attribute_error(
player_career_parser: PlayerCareerParser,
player_html_data: str,
player_search_response_mock: Mock,
):
player_attr_error = player_html_data.replace(
'class="Profile-player--summaryWrapper"',
'class="blabla"',
)

with (
patch(
"httpx.AsyncClient.get",
side_effect=[
# Players search call first
player_search_response_mock,
# Player profile page
Mock(status_code=status.HTTP_200_OK, text=player_attr_error),
],
),
pytest.raises(ParserParsingError) as error,
):
await player_career_parser.parse()

assert (
error.value.message
== "AttributeError(\"'NoneType' object has no attribute 'find'\")"
)


@pytest.mark.parametrize(
("player_career_parser", "player_html_data"),
[("TeKrop-2217", "TeKrop-2217")],
indirect=["player_career_parser", "player_html_data"],
)
@pytest.mark.asyncio
async def test_player_career_parser_parsing_error_key_error(
player_career_parser: PlayerCareerParser,
player_html_data: str,
player_search_response_mock: Mock,
):
player_key_error = re.sub(
'class="Profile-playerSummary--endorsement" src="[^"]*"',
'class="Profile-playerSummary--endorsement"',
player_html_data,
)

with (
patch(
"httpx.AsyncClient.get",
side_effect=[
# Players search call first
player_search_response_mock,
# Player profile page
Mock(status_code=status.HTTP_200_OK, text=player_key_error),
],
),
pytest.raises(ParserParsingError) as error,
):
await player_career_parser.parse()

assert error.value.message == "KeyError('src')"


@pytest.mark.parametrize(
("player_career_parser", "player_html_data"),
[("TeKrop-2217", "TeKrop-2217")],
indirect=["player_career_parser", "player_html_data"],
)
@pytest.mark.asyncio
async def test_player_career_parser_parsing_error_type_error(
player_career_parser: PlayerCareerParser,
player_html_data: str,
player_search_response_mock: Mock,
):
player_type_error = player_html_data.replace(
'class="Profile-playerSummary--endorsement"',
"",
)

with (
patch(
"httpx.AsyncClient.get",
side_effect=[
# Players search call first
player_search_response_mock,
# Player profile page
Mock(status_code=status.HTTP_200_OK, text=player_type_error),
],
),
pytest.raises(ParserParsingError) as error,
):
await player_career_parser.parse()

assert (
error.value.message == "TypeError(\"'NoneType' object is not subscriptable\")"
)
Loading

0 comments on commit 375469f

Please sign in to comment.