Skip to content

Commit

Permalink
Merge pull request #9 from airtai/first-sheet-endpoint
Browse files Browse the repository at this point in the history
First sheet endpoint
  • Loading branch information
rjambrecic committed Jun 19, 2024
2 parents 65c0352 + b14d692 commit 313123e
Show file tree
Hide file tree
Showing 7 changed files with 330 additions and 147 deletions.
13 changes: 12 additions & 1 deletion .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ venv*
htmlcov
token
.DS_Store

client_secret*.json
185 changes: 127 additions & 58 deletions google_sheets/app.py
Original file line number Diff line number Diff line change
@@ -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"]

Expand All @@ -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
34 changes: 34 additions & 0 deletions google_sheets/db_helpers.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
20 changes: 20 additions & 0 deletions schema.prisma
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit 313123e

Please sign in to comment.