diff --git a/backend/api_app/controllers/networks.py b/backend/api_app/controllers/networks.py new file mode 100644 index 0000000..e822f6a --- /dev/null +++ b/backend/api_app/controllers/networks.py @@ -0,0 +1,76 @@ +"""API endoipoints for app networks. + +/networks/ returns list of top networks. +""" + +from typing import Self + +from litestar import Controller, get +from litestar.exceptions import NotFoundException + +from api_app.models import NetworkApps, TopNetworks +from config import get_logger +from dbcon.queries import get_apps_for_network, get_top_networks + +logger = get_logger(__name__) + + +def networks_overview() -> TopNetworks: + """Process networks and return TopNetworks class.""" + df = get_top_networks() + df = df[~df["network_name"].isna()] + df = df.sort_values("app_count", ascending=False) + networks = TopNetworks(networks=df.to_dict(orient="records")) + return networks + + +class NetworksController(Controller): + + """API EndPoint return for app networks.""" + + path = "/api/networks/" + + @get(path="/", cache=True) + async def top_networks(self: Self) -> TopNetworks: + """Handle GET request for a list of top networks. + + Returns + ------- + A dictionary representation of the list of networks + each with an id, name, type and total of apps. + + """ + logger.info(f"{self.path} start") + overview = networks_overview() + + return overview + + @get(path="/{network_name:str}", cache=3600) + async def get_network_apps(self: Self, network_name: str) -> NetworkApps: + """Handle GET request for a specific network. + + Args: + ---- + network_name (str): The name of the network to retrieve apps for. + + Returns: + ------- + json + + """ + logger.info(f"{self.path} start") + apps_df = get_apps_for_network(network_name) + + if apps_df.empty: + msg = f"Network Name not found: {network_name!r}" + raise NotFoundException( + msg, + status_code=404, + ) + apps_dict = apps_df.to_dict(orient="records") + + apps = NetworkApps( + title=network_name, + apps=apps_dict, + ) + return apps diff --git a/backend/api_app/models.py b/backend/api_app/models.py index 3abe674..aeb4767 100644 --- a/backend/api_app/models.py +++ b/backend/api_app/models.py @@ -68,6 +68,15 @@ class TrackerApps: apps: list[AppDetail] +@dataclass +class NetworkApps: + + """A network's list of apps.""" + + title: str + apps: list[AppDetail] + + @dataclass class Collection: @@ -124,6 +133,19 @@ class TrackerDetail: count: int +@dataclass +class NetworkDetail: + + """Describes details of a network. + + Includes its db identifier, name, and the count of its occurrences. + """ + + network: int + network_name: str + count: int + + @dataclass class TopTrackers: @@ -132,6 +154,14 @@ class TopTrackers: trackers: list[TrackerDetail] +@dataclass +class TopNetworks: + + """Contains a list of NetworkDetail objects representing the top networks identified.""" + + networks: list[NetworkDetail] + + @dataclass class StoreCategoryDetail: diff --git a/backend/app.py b/backend/app.py index 0305cb9..b03c5a7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -8,6 +8,7 @@ from api_app.controllers.apps import AppController from api_app.controllers.categories import CategoryController +from api_app.controllers.networks import NetworksController from api_app.controllers.rankings import RankingsController from api_app.controllers.trackers import TrackersController @@ -39,6 +40,7 @@ class MyOpenAPIController(OpenAPIController): CategoryController, RankingsController, TrackersController, + NetworksController, ], cors_config=cors_config, openapi_config=OpenAPIConfig( diff --git a/backend/dbcon/queries.py b/backend/dbcon/queries.py index b1bf215..6527861 100644 --- a/backend/dbcon/queries.py +++ b/backend/dbcon/queries.py @@ -40,6 +40,8 @@ def load_sql_file(file_name: str) -> str: ) QUERY_TOP_TRACKERS = load_sql_file("query_top_trackers.sql") QUERY_TRACKER_APPS = load_sql_file("query_tracker_apps.sql") +QUERY_TOP_NETWORKS = load_sql_file("query_top_networks.sql") +QUERY_NETWORK_APPS = load_sql_file("query_network_apps.sql") def get_recent_apps(collection: str, limit: int = 20) -> pd.DataFrame: @@ -275,6 +277,19 @@ def get_apps_for_tracker(tracker_name: str) -> pd.DataFrame: return df +def get_apps_for_network(network_name: str) -> pd.DataFrame: + """Get apps for for a network.""" + logger.info(f"Tracker: {network_name=}") + df = pd.read_sql( + QUERY_NETWORK_APPS, + con=DBCON.engine, + params={"network_name": network_name, "mylimit": 20}, + ) + if not df.empty: + df = clean_app_df(df) + return df + + def search_apps(search_input: str, limit: int = 100) -> pd.DataFrame: """Search apps by term in database.""" logger.info(f"App search: {search_input=}") @@ -307,6 +322,16 @@ def get_top_trackers() -> pd.DataFrame: return df +def get_top_networks() -> pd.DataFrame: + """Get top networks. + + Data is pre-processed by materialized views. + + """ + df = pd.read_sql(QUERY_TOP_NETWORKS, DBCON.engine) + return df + + logger.info("set db engine") DBCON = get_db_connection("madrone") DBCON.set_engine() diff --git a/backend/dbcon/sql/query_network_apps.sql b/backend/dbcon/sql/query_network_apps.sql new file mode 100644 index 0000000..c31116f --- /dev/null +++ b/backend/dbcon/sql/query_network_apps.sql @@ -0,0 +1,17 @@ +SELECT * +FROM + store_apps +WHERE + id IN + ( + SELECT sat.store_app + FROM + store_apps_networks AS sat + LEFT JOIN networks AS t + ON + sat.network = t.id + WHERE + t.name = :network_name + ) +ORDER BY installs DESC +LIMIT :mylimit; diff --git a/backend/dbcon/sql/query_top_networks.sql b/backend/dbcon/sql/query_top_networks.sql new file mode 100644 index 0000000..5b4fbca --- /dev/null +++ b/backend/dbcon/sql/query_top_networks.sql @@ -0,0 +1,31 @@ +WITH +network_counts AS ( + SELECT + network, + count(DISTINCT store_app) AS app_count + FROM + store_apps_networks + GROUP BY + network +), + +total_app_count AS ( + SELECT count(DISTINCT store_app) + FROM + store_apps_networks +) + +SELECT + t.name AS network_name, + tc.app_count, + total_app_count.count AS total_app_count, + ( + tc.app_count / total_app_count.count::decimal + ) AS percent +FROM + network_counts AS tc +LEFT JOIN networks AS t + ON + tc.network = t.id +INNER JOIN total_app_count ON + TRUE; diff --git a/frontend/src/lib/NavTabs.svelte b/frontend/src/lib/NavTabs.svelte index 24e5f2b..f1ff05d 100644 --- a/frontend/src/lib/NavTabs.svelte +++ b/frontend/src/lib/NavTabs.svelte @@ -30,6 +30,9 @@

TRACKERS

+

NETWORKS

ABOUT

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index be6c2ae..4d63444 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -149,6 +149,19 @@ export interface TopTrackersInfo { }; } +export interface NetworkDetail { + network_name: string; + app_count: number; + percent: number; +} + +export interface TopNetworksInfo { + status?: number; + error?: string; + networks: { + streamed: Promise<{ networks: NetworkDetail[] }>; + }; +} export interface AppHistoryInfo { crawled_date: string; review_count: number;