diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c15db06..c19c0fc 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -70,7 +70,10 @@ jobs: if: matrix.pydantic-version == 'pydantic-v2' run: pip install --pre "pydantic>=2,<3" - run: mkdir coverage - + - name: Create client secrets file + run: echo '{"web":{"client_id":"dummy.apps.googleusercontent.com","project_id":"dummy-id","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"dummy-secret","redirect_uris":["http://localhost:9000/login/callback"]}}' > client_secret.json + - name: Prisma generate + run: prisma generate - name: Test run: bash scripts/test.sh env: @@ -98,6 +101,10 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: pip install .[docs,testing] + - name: Create client secrets file + run: echo '{"web":{"client_id":"dummy.apps.googleusercontent.com","project_id":"dummy-id","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"dummy-secret","redirect_uris":["http://localhost:9000/login/callback"]}}' > client_secret.json + - name: Prisma generate + run: prisma generate - name: Test run: bash scripts/test.sh @@ -116,6 +123,10 @@ jobs: - name: Install Dependencies if: steps.cache.outputs.cache-hit != 'true' run: pip install .[docs,testing] + - name: Create client secrets file + run: echo '{"web":{"client_id":"dummy.apps.googleusercontent.com","project_id":"dummy-id","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"dummy-secret","redirect_uris":["http://localhost:9000/login/callback"]}}' > client_secret.json + - name: Prisma generate + run: prisma generate - name: Test run: bash scripts/test.sh diff --git a/.gitignore b/.gitignore index 9e5161e..9372fbb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ venv* htmlcov token .DS_Store + +client_secret*.json diff --git a/google_sheets/app.py b/google_sheets/app.py index 0482918..3bcd57c 100644 --- a/google_sheets/app.py +++ b/google_sheets/app.py @@ -1,13 +1,17 @@ -import datetime +import json import logging from os import environ -from typing import Annotated, List +from pathlib import Path +from typing import Annotated, Any, Dict, List, Union -import python_weather -from fastapi import FastAPI, Query -from pydantic import BaseModel +from asyncify import asyncify +from fastapi import FastAPI, HTTPException, Query +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from prisma.errors import RecordNotFoundError from . import __version__ +from .db_helpers import get_db_connection, get_wasp_db_url __all__ = ["app"] @@ -21,61 +25,126 @@ ) app = FastAPI( - servers=[{"url": base_url, "description": "Weather app server"}], + servers=[{"url": base_url, "description": "Google Sheets app server"}], version=__version__, title="google-sheets", ) - -class HourlyForecast(BaseModel): - forecast_time: datetime.time - temperature: int - description: str - - -class DailyForecast(BaseModel): - forecast_date: datetime.date - temperature: int - hourly_forecasts: List[HourlyForecast] - - -class Weather(BaseModel): - city: str - temperature: int - daily_forecasts: List[DailyForecast] - - -@app.get("/", description="Get weather forecast for a given city") -async def get_weather( - city: Annotated[str, Query(description="city for which forecast is requested")], -) -> Weather: - async with python_weather.Client(unit=python_weather.METRIC) as client: - # fetch a weather forecast from a city - weather = await client.get(city) - - daily_forecasts = [] - # get the weather forecast for a few days - for daily in weather.daily_forecasts: - hourly_forecasts = [ - HourlyForecast( - forecast_time=hourly.time, - temperature=hourly.temperature, - description=hourly.description, - ) - for hourly in daily.hourly_forecasts - ] - daily_forecasts.append( - DailyForecast( - forecast_date=daily.date, - temperature=daily.temperature, - hourly_forecasts=hourly_forecasts, - ) - ) - - weather_response = Weather( - city=city, - temperature=weather.temperature, - daily_forecasts=daily_forecasts, - hourly_forecasts=hourly_forecasts, +# Load client secret data from the JSON file +with Path("client_secret.json").open() as secret_file: + client_secret_data = json.load(secret_file) + +# OAuth2 configuration +oauth2_settings = { + "auth_uri": client_secret_data["web"]["auth_uri"], + "tokenUrl": client_secret_data["web"]["token_uri"], + "clientId": client_secret_data["web"]["client_id"], + "clientSecret": client_secret_data["web"]["client_secret"], + "redirectUri": client_secret_data["web"]["redirect_uris"][0], +} + + +async def get_user(user_id: Union[int, str]) -> Any: + wasp_db_url = await get_wasp_db_url() + async with get_db_connection(db_url=wasp_db_url) as db: + user = await db.query_first( + f'SELECT * from "User" where id={user_id}' # nosec: [B608] + ) + if not user: + raise HTTPException(status_code=404, detail=f"user_id {user_id} not found") + return user + + +async def load_user_credentials(user_id: Union[int, str]) -> Any: + await get_user(user_id=user_id) + async with get_db_connection() as db: + try: + data = await db.gauth.find_unique_or_raise(where={"user_id": user_id}) # type: ignore[typeddict-item] + except RecordNotFoundError as e: + raise HTTPException( + status_code=404, detail="User hasn't grant access yet!" + ) from e + + return data.creds + + +async def _build_service(user_id: int, service_name: str, version: str) -> Any: + user_credentials = await load_user_credentials(user_id) + sheets_credentials: Dict[str, str] = { + "refresh_token": user_credentials["refresh_token"], + "client_id": oauth2_settings["clientId"], + "client_secret": oauth2_settings["clientSecret"], + } + + creds = Credentials.from_authorized_user_info( # type: ignore[no-untyped-call] + info=sheets_credentials, + scopes=[ + "https://www.googleapis.com/auth/spreadsheets", + "https://www.googleapis.com/auth/drive.metadata.readonly", + ], + ) + service = build(serviceName=service_name, version=version, credentials=creds) + return service + + +@asyncify # type: ignore[misc] +def _get_sheet(service: Any, spreadsheet_id: str, range: str) -> Any: + # Call the Sheets API + sheet = service.spreadsheets() + result = sheet.values().get(spreadsheetId=spreadsheet_id, range=range).execute() + values = result.get("values", []) + + return values + + +@app.get("/sheet", description="Get data from a Google Sheet") +async def get_sheet( + user_id: Annotated[ + int, Query(description="The user ID for which the data is requested") + ], + spreadsheet_id: Annotated[ + str, Query(description="ID of the Google Sheet to fetch data from") + ], + range: Annotated[ + str, + Query(description="The range of cells to fetch data from. E.g. 'Sheet1!A1:B2'"), + ], +) -> Union[str, List[List[str]]]: + service = await _build_service(user_id=user_id, service_name="sheets", version="v4") + values = await _get_sheet( + service=service, spreadsheet_id=spreadsheet_id, range=range + ) + + if not values: + return "No data found." + + return values # type: ignore[no-any-return] + + +@asyncify # type: ignore[misc] +def _get_files(service: Any) -> List[Dict[str, str]]: + # Call the Drive v3 API + results = ( + service.files() + .list( + q="mimeType='application/vnd.google-apps.spreadsheet'", + pageSize=100, # The default value is 100 + fields="nextPageToken, files(id, name)", ) - return weather_response + .execute() + ) + items = results.get("files", []) + return items # type: ignore[no-any-return] + + +@app.get("/get-all-file-names", description="Get all sheets associated with the user") +async def get_all_file_names( + user_id: Annotated[ + int, Query(description="The user ID for which the data is requested") + ], +) -> Dict[str, str]: + service = await _build_service(user_id=user_id, service_name="drive", version="v3") + files: List[Dict[str, str]] = await _get_files(service=service) + # create dict where key is id and value is name + files_dict = {file["id"]: file["name"] for file in files} + return files_dict diff --git a/google_sheets/db_helpers.py b/google_sheets/db_helpers.py new file mode 100644 index 0000000..928e4b7 --- /dev/null +++ b/google_sheets/db_helpers.py @@ -0,0 +1,34 @@ +from contextlib import asynccontextmanager +from os import environ +from typing import AsyncGenerator, Optional + +from prisma import Prisma # type: ignore[attr-defined] + + +@asynccontextmanager +async def get_db_connection( + db_url: Optional[str] = None, +) -> AsyncGenerator[Prisma, None]: + if not db_url: + db_url = environ.get("DATABASE_URL", None) + if not db_url: + raise ValueError( + "No database URL provided nor set as environment variable 'DATABASE_URL'" + ) # pragma: no cover + if "connect_timeout" not in db_url: + db_url += "?connect_timeout=60" + db = Prisma(datasource={"url": db_url}) + await db.connect() + try: + yield db + finally: + await db.disconnect() + + +async def get_wasp_db_url() -> str: + curr_db_url = environ.get("DATABASE_URL") + wasp_db_name = environ.get("WASP_DB_NAME", "waspdb") + wasp_db_url = curr_db_url.replace(curr_db_url.split("/")[-1], wasp_db_name) # type: ignore[union-attr] + if "connect_timeout" not in wasp_db_url: + wasp_db_url += "?connect_timeout=60" + return wasp_db_url diff --git a/pyproject.toml b/pyproject.toml index f9fa5e1..4212252 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,9 @@ dynamic = ["version"] dependencies = [ "pydantic>=2.3,<3", "fastapi>=0.110.2", - "python-weather==2.0.3", + "prisma==0.13.1", + "google-api-python-client==2.133.0", + "asyncify==0.10.0", ] [project.optional-dependencies] diff --git a/schema.prisma b/schema.prisma new file mode 100644 index 0000000..24477d1 --- /dev/null +++ b/schema.prisma @@ -0,0 +1,20 @@ +datasource db { + // could be postgresql or mysql + provider = "postgresql" + url = env("DATABASE_URL") +} + +generator db { + provider = "prisma-client-py" + interface = "asyncio" + recursive_type_depth = 5 +} + +model GAuth { + id String @id @default(cuid()) + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + user_id Int @unique + creds Json + info Json +} diff --git a/tests/app/test_app.py b/tests/app/test_app.py index 3a27068..af8e8ba 100644 --- a/tests/app/test_app.py +++ b/tests/app/test_app.py @@ -1,4 +1,4 @@ -import datetime +from unittest.mock import patch from fastapi.testclient import TestClient @@ -9,56 +9,99 @@ class TestRoutes: - def test_weather_route(self) -> None: - response = client.get("/?city=Chennai") - assert response.status_code == 200 - resp_json = response.json() - assert resp_json.get("city") == "Chennai" - assert resp_json.get("temperature") > 0 - - assert len(resp_json.get("daily_forecasts")) > 0 - daily_forecasts = resp_json.get("daily_forecasts") - assert isinstance(daily_forecasts, list) + def test_get_sheet(self) -> None: + with patch( + "google_sheets.app.load_user_credentials", + return_value={"refresh_token": "abcdf"}, + ) as mock_load_user_credentials: + excepted = [ + ["Campaign", "Ad Group", "Keyword"], + ["Campaign A", "Ad group A", "Keyword A"], + ["Campaign A", "Ad group A", "Keyword B"], + ["Campaign A", "Ad group A", "Keyword C"], + ] + with patch( + "google_sheets.app._get_sheet", return_value=excepted + ) as mock_get_sheet: + response = client.get( + "/sheet?user_id=123&spreadsheet_id=abc&range=Sheet1" + ) + mock_load_user_credentials.assert_called_once() + mock_get_sheet.assert_called_once() + assert response.status_code == 200 + assert response.json() == excepted - first_daily_forecast = daily_forecasts[0] - assert ( - first_daily_forecast.get("forecast_date") - == datetime.date.today().isoformat() - ) - assert first_daily_forecast.get("temperature") > 0 - assert len(first_daily_forecast.get("hourly_forecasts")) > 0 - - first_hourly_forecast = first_daily_forecast.get("hourly_forecasts")[0] - assert isinstance(first_hourly_forecast, dict) - assert first_hourly_forecast.get("forecast_time") is not None - assert first_hourly_forecast.get("temperature") > 0 # type: ignore - assert first_hourly_forecast.get("description") is not None + def test_get_all_file_names(self) -> None: + with ( + patch( + "google_sheets.app.load_user_credentials", + return_value={"refresh_token": "abcdf"}, + ) as mock_load_user_credentials, + patch( + "google_sheets.app._get_files", + return_value=[ + {"id": "abc", "name": "file1"}, + {"id": "def", "name": "file2"}, + ], + ) as mock_get_files, + ): + expected = {"abc": "file1", "def": "file2"} + response = client.get("/get-all-file-names?user_id=123") + mock_load_user_credentials.assert_called_once() + mock_get_files.assert_called_once() + assert response.status_code == 200 + assert response.json() == expected def test_openapi(self) -> None: expected = { "openapi": "3.1.0", "info": {"title": "google-sheets", "version": version}, "servers": [ - {"url": "http://localhost:8000", "description": "Weather app server"} + { + "url": "http://localhost:8000", + "description": "Google Sheets app server", + } ], "paths": { - "/": { + "/sheet": { "get": { - "summary": "Get Weather", - "operationId": "get_weather__get", - "description": "Get weather forecast for a given city", + "summary": "Get Sheet", + "description": "Get data from a Google Sheet", + "operationId": "get_sheet_sheet_get", "parameters": [ { - "name": "city", + "name": "user_id", + "in": "query", + "required": True, + "schema": { + "type": "integer", + "description": "The user ID for which the data is requested", + "title": "User Id", + }, + "description": "The user ID for which the data is requested", + }, + { + "name": "spreadsheet_id", "in": "query", - "description": "city for which forecast is requested", "required": True, "schema": { "type": "string", - "title": "City", - "description": "city for which forecast is requested", + "description": "ID of the Google Sheet to fetch data from", + "title": "Spreadsheet Id", }, - } + "description": "ID of the Google Sheet to fetch data from", + }, + { + "name": "range", + "in": "query", + "required": True, + "schema": { + "type": "string", + "description": "The range of cells to fetch data from. E.g. 'Sheet1!A1:B2'", + "title": "Range", + }, + "description": "The range of cells to fetch data from. E.g. 'Sheet1!A1:B2'", + }, ], "responses": { "200": { @@ -66,7 +109,17 @@ def test_openapi(self) -> None: "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Weather" + "anyOf": [ + {"type": "string"}, + { + "type": "array", + "items": { + "type": "array", + "items": {"type": "string"}, + }, + }, + ], + "title": "Response Get Sheet Sheet Get", } } }, @@ -83,34 +136,54 @@ def test_openapi(self) -> None: }, }, } - } - }, - "components": { - "schemas": { - "DailyForecast": { - "properties": { - "forecast_date": { - "type": "string", - "format": "date", - "title": "Forecast Date", + }, + "/get-all-file-names": { + "get": { + "summary": "Get All File Names", + "description": "Get all sheets associated with the user", + "operationId": "get_all_file_names_get_all_file_names_get", + "parameters": [ + { + "name": "user_id", + "in": "query", + "required": True, + "schema": { + "type": "integer", + "description": "The user ID for which the data is requested", + "title": "User Id", + }, + "description": "The user ID for which the data is requested", + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": {"type": "string"}, + "title": "Response Get All File Names Get All File Names Get", + } + } + }, }, - "temperature": {"type": "integer", "title": "Temperature"}, - "hourly_forecasts": { - "items": { - "$ref": "#/components/schemas/HourlyForecast" + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } }, - "type": "array", - "title": "Hourly Forecasts", }, }, - "type": "object", - "required": [ - "forecast_date", - "temperature", - "hourly_forecasts", - ], - "title": "DailyForecast", - }, + } + }, + }, + "components": { + "schemas": { "HTTPValidationError": { "properties": { "detail": { @@ -124,20 +197,6 @@ def test_openapi(self) -> None: "type": "object", "title": "HTTPValidationError", }, - "HourlyForecast": { - "properties": { - "forecast_time": { - "type": "string", - "format": "time", - "title": "Forecast Time", - }, - "temperature": {"type": "integer", "title": "Temperature"}, - "description": {"type": "string", "title": "Description"}, - }, - "type": "object", - "required": ["forecast_time", "temperature", "description"], - "title": "HourlyForecast", - }, "ValidationError": { "properties": { "loc": { @@ -154,20 +213,6 @@ def test_openapi(self) -> None: "required": ["loc", "msg", "type"], "title": "ValidationError", }, - "Weather": { - "properties": { - "city": {"type": "string", "title": "City"}, - "temperature": {"type": "integer", "title": "Temperature"}, - "daily_forecasts": { - "items": {"$ref": "#/components/schemas/DailyForecast"}, - "type": "array", - "title": "Daily Forecasts", - }, - }, - "type": "object", - "required": ["city", "temperature", "daily_forecasts"], - "title": "Weather", - }, } }, }