added supabase routes and payment handling
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m34s
Run linting on the backend code / Build (pull_request) Failing after 18s
Run testing on the backend code / Build (pull_request) Failing after 38s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 1m5s
Some checks failed
Build and deploy the backend to staging / Build and push image (pull_request) Successful in 1m34s
Run linting on the backend code / Build (pull_request) Failing after 18s
Run testing on the backend code / Build (pull_request) Failing after 38s
Build and deploy the backend to staging / Deploy to staging (pull_request) Successful in 1m5s
This commit is contained in:
@@ -10,7 +10,8 @@ from .structs.trip import Trip
|
||||
from .landmarks.landmarks_manager import LandmarkManager
|
||||
from .toilets.toilets_router import router as toilets_router
|
||||
from .optimization.optimization_router import router as optimization_router
|
||||
from .landmarks.landmarks_router import router as landmarks_router, get_landmarks_nearby
|
||||
from .landmarks.landmarks_router import router as landmarks_router
|
||||
from .payments.payment_router import router as payment_router
|
||||
from .optimization.optimizer import Optimizer
|
||||
from .optimization.refiner import Refiner
|
||||
from .cache import client as cache_client
|
||||
@@ -42,16 +43,18 @@ app = FastAPI(lifespan=lifespan)
|
||||
app.include_router(landmarks_router)
|
||||
|
||||
|
||||
# Optimizes the trip given preferences. Second step in the main trip generation pipeline
|
||||
# Optimizes the trip given preferences. Second step in the main trip generation pipeline.
|
||||
# Call with "/optimize/trip"
|
||||
app.include_router(optimization_router)
|
||||
|
||||
|
||||
# Fetches toilets near given coordinates.
|
||||
# Call with "/get/toilets" for fetching toilets around coordinates
|
||||
# Call with "/get/toilets" for fetching toilets around coordinates.
|
||||
app.include_router(toilets_router)
|
||||
|
||||
|
||||
# Include the payment router for interacting with paypal sdk.
|
||||
# See src/payment/payment_router.py for more information on how to call.
|
||||
app.include_router(payment_router)
|
||||
|
||||
#### For already existing trips/landmarks
|
||||
@app.get("/trip/{trip_uuid}")
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import logging
|
||||
import time
|
||||
import yaml
|
||||
from fastapi import HTTPException, APIRouter, BackgroundTasks
|
||||
from fastapi import HTTPException, APIRouter, BackgroundTasks, Body
|
||||
|
||||
from .optimizer import Optimizer
|
||||
from .refiner import Refiner
|
||||
from ..supabase.supabase import SupabaseClient
|
||||
from ..structs.landmark import Landmark
|
||||
from ..structs.preferences import Preferences
|
||||
from ..structs.linked_landmarks import LinkedLandmarks
|
||||
@@ -20,6 +21,7 @@ from ..constants import OPTIMIZER_PARAMETERS_PATH
|
||||
logger = logging.getLogger(__name__)
|
||||
optimizer = Optimizer()
|
||||
refiner = Refiner(optimizer=optimizer)
|
||||
supabase = SupabaseClient()
|
||||
|
||||
|
||||
# Initialize the API router
|
||||
@@ -28,10 +30,11 @@ router = APIRouter()
|
||||
|
||||
@router.post("/optimize/trip")
|
||||
def optimize_trip(
|
||||
preferences: Preferences,
|
||||
landmarks: list[Landmark],
|
||||
start: tuple[float, float],
|
||||
end: tuple[float, float] | None = None,
|
||||
user_id: str = Body(...),
|
||||
preferences: Preferences = Body(...),
|
||||
landmarks: list[Landmark] = Body(...),
|
||||
start: tuple[float, float] = Body(...),
|
||||
end: tuple[float, float] | None = Body(None),
|
||||
background_tasks: BackgroundTasks = None
|
||||
) -> Trip:
|
||||
"""
|
||||
@@ -45,6 +48,22 @@ def optimize_trip(
|
||||
Returns:
|
||||
(uuid) : The uuid of the first landmark in the optimized route
|
||||
"""
|
||||
# Check for valid user balance
|
||||
try:
|
||||
if not supabase.check_balance(user_id=user_id):
|
||||
logger.warning('Insufficient credits to perform this action.')
|
||||
raise HTTPException(status_code=418, detail='Insufficient credits')
|
||||
except SyntaxError as se :
|
||||
logger.error(f'SyntaxError: {se}')
|
||||
raise HTTPException(status_code=400, detail=str(se)) from se
|
||||
except ValueError as ve :
|
||||
logger.error(f'SyntaxError: {ve}')
|
||||
raise HTTPException(status_code=406, detail=str(ve)) from ve
|
||||
except Exception as exc:
|
||||
logger.error(f'SyntaxError: {exc}')
|
||||
raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(exc)}") from exc
|
||||
|
||||
# Check for invalid input
|
||||
if preferences is None:
|
||||
raise HTTPException(status_code=406, detail="Preferences not provided or incomplete.")
|
||||
if len(landmarks) == 0 :
|
||||
@@ -135,7 +154,11 @@ def optimize_trip(
|
||||
logger.info(f'Optimized a trip of {trip.total_time} minutes with {len(refined_tour)} landmarks in {round(t_first_stage + t_second_stage,3)} seconds.')
|
||||
logger.info('Detailed trip :\n\t' + '\n\t'.join(f'{landmark}' for landmark in refined_tour))
|
||||
|
||||
# Add the cache fill as background task
|
||||
background_tasks.add_task(fill_cache)
|
||||
|
||||
# Finally, decrement the user balance
|
||||
supabase.decrement_credit_balance(user_id=user_id)
|
||||
|
||||
return trip
|
||||
|
||||
|
||||
70
backend/src/payments/payment_handler.py
Normal file
70
backend/src/payments/payment_handler.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from typing import Literal
|
||||
import paypalrestsdk
|
||||
from pydantic import BaseModel
|
||||
from fastapi import HTTPException
|
||||
import logging
|
||||
|
||||
|
||||
# Model for payment request body
|
||||
class PaymentRequest(BaseModel):
|
||||
user_id: str
|
||||
credit_amount: Literal[10, 50, 100]
|
||||
currency: Literal["USD", "EUR", "CHF"]
|
||||
description: str = "Purchase of credits"
|
||||
|
||||
|
||||
# Payment handler class for managing PayPal payments
|
||||
class PaymentHandler:
|
||||
|
||||
payment_id: str
|
||||
|
||||
def __init__(self, transaction_details: PaymentRequest):
|
||||
self.details = transaction_details
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Only support purchase of credit 'bundles': 10, 50 or 100 credits worth of trip generation
|
||||
def fetch_price(self) -> float:
|
||||
"""
|
||||
Fetches the price of credits in the specified currency.
|
||||
"""
|
||||
result = self.supabase.table("prices").select("credit_amount").eq("currency", self.details.currency).single().execute()
|
||||
if result.data:
|
||||
return result.data.get("price")
|
||||
else:
|
||||
self.logger.error(f"Unsupported currency: {self.details.currency}")
|
||||
return None
|
||||
|
||||
def create_paypal_payment(self) -> str:
|
||||
"""
|
||||
Creates a PayPal payment and returns the approval URL.
|
||||
"""
|
||||
price = self.fetch_price()
|
||||
payment = paypalrestsdk.Payment({
|
||||
"intent": "sale",
|
||||
"payer": {
|
||||
"payment_method": "paypal"
|
||||
},
|
||||
"transactions": [{
|
||||
"amount": {
|
||||
"total": f"{price:.2f}",
|
||||
"currency": self.details.currency
|
||||
},
|
||||
"description": self.details.description
|
||||
}],
|
||||
"redirect_urls": {
|
||||
"return_url": "http://localhost:8000/payment/success",
|
||||
"cancel_url": "http://localhost:8000/payment/cancel"
|
||||
}
|
||||
})
|
||||
|
||||
if payment.create():
|
||||
self.logger.info("Payment created successfully")
|
||||
self.payment_id = payment.id
|
||||
|
||||
# Get the approval URL and return it for the user to approve
|
||||
for link in payment.links:
|
||||
if link.rel == "approval_url":
|
||||
return link.href
|
||||
else:
|
||||
self.logger.error(f"Failed to create payment: {payment.error}")
|
||||
raise HTTPException(status_code=500, detail="Payment creation failed")
|
||||
79
backend/src/payments/payment_router.py
Normal file
79
backend/src/payments/payment_router.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import logging
|
||||
import paypalrestsdk
|
||||
from fastapi import HTTPException, APIRouter
|
||||
|
||||
from ..supabase.supabase import SupabaseClient
|
||||
from .payment_handler import PaymentRequest, PaymentHandler
|
||||
|
||||
# Set up logging and supabase
|
||||
logger = logging.getLogger(__name__)
|
||||
supabase = SupabaseClient()
|
||||
|
||||
# Configure PayPal SDK
|
||||
paypalrestsdk.configure({
|
||||
"mode": "sandbox", # Use 'live' for production
|
||||
"client_id": "YOUR_PAYPAL_CLIENT_ID",
|
||||
"client_secret": "YOUR_PAYPAL_SECRET"
|
||||
})
|
||||
|
||||
|
||||
# Define the API router
|
||||
router = APIRouter()
|
||||
|
||||
@router.post("/purchase/credits")
|
||||
def purchase_credits(payment_request: PaymentRequest):
|
||||
"""
|
||||
Handles token purchases. Calculates the number of tokens based on the amount paid,
|
||||
updates the user's balance, and processes PayPal payment.
|
||||
"""
|
||||
payment_handler = PaymentHandler(payment_request)
|
||||
|
||||
# Create PayPal payment and get the approval URL
|
||||
approval_url = payment_handler.create_paypal_payment()
|
||||
|
||||
return {
|
||||
"message": "Purchase initiated successfully",
|
||||
"payment_id": payment_handler.payment_id,
|
||||
"credits": payment_request.credit_amount,
|
||||
"approval_url": approval_url,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/payment/success")
|
||||
def payment_success(paymentId: str, PayerID: str):
|
||||
"""
|
||||
Handles successful PayPal payment.
|
||||
"""
|
||||
payment = paypalrestsdk.Payment.find(paymentId)
|
||||
|
||||
if payment.execute({"payer_id": PayerID}):
|
||||
logger.info("Payment executed successfully")
|
||||
|
||||
# Retrieve transaction details from the database
|
||||
result = supabase.table("pending_payments").select("*").eq("payment_id", paymentId).single().execute()
|
||||
if not result.data:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
|
||||
# Extract the necessary information
|
||||
user_id = result.data["user_id"]
|
||||
credit_amount = result.data["credit_amount"]
|
||||
|
||||
# Update the user's balance
|
||||
supabase.increment_credit_balance(user_id, amount=credit_amount)
|
||||
|
||||
# Optionally, delete the pending payment entry since the transaction is completed
|
||||
supabase.table("pending_payments").delete().eq("payment_id", paymentId).execute()
|
||||
|
||||
return {"message": "Payment completed successfully"}
|
||||
else:
|
||||
logger.error(f"Payment execution failed: {payment.error}")
|
||||
raise HTTPException(status_code=500, detail="Payment execution failed")
|
||||
|
||||
|
||||
@router.get("/payment/cancel")
|
||||
def payment_cancel():
|
||||
"""
|
||||
Handles PayPal payment cancellation.
|
||||
"""
|
||||
return {"message": "Payment was cancelled"}
|
||||
|
||||
169
backend/src/supabase/supabase.py
Normal file
169
backend/src/supabase/supabase.py
Normal file
@@ -0,0 +1,169 @@
|
||||
import os
|
||||
import logging
|
||||
import yaml
|
||||
from fastapi import HTTPException, status
|
||||
from supabase import create_client, Client, ClientOptions
|
||||
|
||||
from ..constants import PARAMETERS_DIR
|
||||
|
||||
# Silence the supabase logger
|
||||
logging.getLogger("httpx").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("hpack").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("httpcore").setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
class SupabaseClient:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def __init__(self):
|
||||
|
||||
with open(os.path.join(PARAMETERS_DIR, 'secrets.yaml')) as f:
|
||||
secrets = yaml.safe_load(f)
|
||||
self.SUPABASE_URL = secrets['SUPABASE_URL']
|
||||
self.SUPABASE_ADMIN_KEY = secrets['SUPABASE_ADMIN_KEY']
|
||||
self.SUPABASE_TEST_USER_ID = secrets['SUPABASE_TEST_USER_ID']
|
||||
|
||||
self.supabase = create_client(
|
||||
self.SUPABASE_URL,
|
||||
self.SUPABASE_ADMIN_KEY,
|
||||
options=ClientOptions(schema='public')
|
||||
)
|
||||
self.logger.info('Supabase client initialized.')
|
||||
|
||||
|
||||
def check_balance(self, user_id: str) -> bool:
|
||||
"""
|
||||
Checks if the user has enough 'credit' for generating a new trip.
|
||||
|
||||
Args:
|
||||
user_id (str): The ID of the current user.
|
||||
|
||||
Returns:
|
||||
bool: True if the balance is positive, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Query the public.credits table to get the user's credits
|
||||
response = (
|
||||
self.supabase.table("credits")
|
||||
.select('*')
|
||||
.eq('id', user_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if e.code == '22P02' :
|
||||
self.logger.error(f"Failed querying credits : {str(e)}")
|
||||
raise SyntaxError(f"Failed querying credits : {str(e)}") from e
|
||||
if e.code == 'PGRST116' :
|
||||
self.logger.error(f"User not found : {str(e)}")
|
||||
raise ValueError(f"User not found : {str(e)}") from e
|
||||
else :
|
||||
self.logger.error(f"An unexpected error occured while checking user balance : {str(e)}")
|
||||
raise Exception(f"An unexpected error occured while checking user balance : {str(e)}") from e
|
||||
|
||||
# Proceed to check the user's credit balance
|
||||
credits = response.data['credit_amount']
|
||||
self.logger.debug(f'Credits of user {user_id}: {credits}')
|
||||
|
||||
if credits > 0:
|
||||
self.logger.info(f'Credit balance is positive for user {user_id}. Proceeding with trip generation.')
|
||||
return True
|
||||
|
||||
self.logger.warning(f'Insufficient balance for user {user_id}. Trip generation cannot proceed.')
|
||||
return False
|
||||
|
||||
|
||||
def decrement_credit_balance(self, user_id: str, amount: int=1) -> bool:
|
||||
"""
|
||||
Decrements the user's credit balance by 1.
|
||||
|
||||
Args:
|
||||
user_id (str): The ID of the current user.
|
||||
"""
|
||||
try:
|
||||
# Query the public.credits table to get the user's current credits
|
||||
response = (
|
||||
self.supabase.table("credits")
|
||||
.select('*')
|
||||
.eq('id', user_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
except Exception as e:
|
||||
if e.code == '22P02' :
|
||||
self.logger.error(f"Failed decrementing credits : {str(e)}")
|
||||
raise SyntaxError(f"Failed decrementing credits : {str(e)}") from e
|
||||
if e.code == 'PGRST116' :
|
||||
self.logger.error(f"User not found : {str(e)}")
|
||||
raise ValueError(f"User not found : {str(e)}") from e
|
||||
else :
|
||||
self.logger.error(f"An unexpected error occured while decrementing user balance : {str(e)}")
|
||||
raise Exception(f"An unexpected error occured while decrementing user balance : {str(e)}") from e
|
||||
|
||||
|
||||
current_credits = response.data['credit_amount']
|
||||
updated_credits = current_credits - amount
|
||||
|
||||
# Update the user's credits in the table
|
||||
update_response = (
|
||||
self.supabase.table('credits')
|
||||
.update({'credit_amount': updated_credits})
|
||||
.eq('id', user_id)
|
||||
.execute()
|
||||
)
|
||||
|
||||
# Check if the update was successful
|
||||
if update_response.data:
|
||||
self.logger.debug(f'Credit balance successfully decremented.')
|
||||
return True
|
||||
else:
|
||||
raise Exception("Error decrementing credit balance.")
|
||||
|
||||
|
||||
def increment_credit_balance(self, user_id: str, amount: int=1) -> bool:
|
||||
"""
|
||||
Increments the user's credit balance by 1.
|
||||
|
||||
Args:
|
||||
user_id (str): The ID of the current user.
|
||||
"""
|
||||
try:
|
||||
# Query the public.credits table to get the user's current credits
|
||||
response = (
|
||||
self.supabase.table("credits")
|
||||
.select('*')
|
||||
.eq('id', user_id)
|
||||
.single()
|
||||
.execute()
|
||||
)
|
||||
except Exception as e:
|
||||
if e.code == '22P02' :
|
||||
self.logger.error(f"Failed incrementing credits : {str(e)}")
|
||||
raise SyntaxError(f"Failed incrementing credits : {str(e)}") from e
|
||||
if e.code == 'PGRST116' :
|
||||
self.logger.error(f"User not found : {str(e)}")
|
||||
raise ValueError(f"User not found : {str(e)}") from e
|
||||
else :
|
||||
self.logger.error(f"An unexpected error occured while incrementing user balance : {str(e)}")
|
||||
raise Exception(f"An unexpected error occured while incrementing user balance : {str(e)}") from e
|
||||
|
||||
|
||||
current_credits = response.data['credit_amount']
|
||||
updated_credits = current_credits + amount
|
||||
|
||||
# Update the user's credits in the table
|
||||
update_response = (
|
||||
self.supabase.table('credits')
|
||||
.update({'credit_amount': updated_credits})
|
||||
.eq('id', user_id)
|
||||
.execute()
|
||||
)
|
||||
|
||||
# Check if the update was successful
|
||||
if update_response.data:
|
||||
self.logger.debug(f'Credit balance successfully incremented.')
|
||||
return True
|
||||
else:
|
||||
raise Exception("Error incrementing credit balance.")
|
||||
@@ -1,13 +1,21 @@
|
||||
"""Collection of tests to ensure correct implementation and track progress."""
|
||||
import os
|
||||
import time
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from .test_utils import load_trip_landmarks, log_trip_details
|
||||
from ..supabase.supabase import SupabaseClient
|
||||
from ..structs.preferences import Preferences, Preference
|
||||
from ..constants import PARAMETERS_DIR
|
||||
from ..main import app
|
||||
|
||||
|
||||
# Create a supabase client
|
||||
supabase = SupabaseClient()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client():
|
||||
"""Client used to call the app."""
|
||||
@@ -53,19 +61,32 @@ def test_trip(client, request, sightseeing, shopping, nature, max_time_minute, s
|
||||
"end": end_coords,
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
landmarks = response.json()
|
||||
|
||||
# Step 2: Feed the landmarks to the optimizer to compute the trip
|
||||
response = client.post(
|
||||
"/optimize/trip",
|
||||
json={
|
||||
"user_id": supabase.SUPABASE_TEST_USER_ID,
|
||||
"preferences": prefs.model_dump(),
|
||||
"landmarks": landmarks,
|
||||
"start": start,
|
||||
"end": end,
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Increment the user balance again
|
||||
supabase.increment_credit_balance(
|
||||
supabase.SUPABASE_TEST_USER_ID,
|
||||
amount=1
|
||||
)
|
||||
|
||||
# Parse the response
|
||||
result = response.json()
|
||||
# print(result)
|
||||
landmarks = load_trip_landmarks(client, result['first_landmark_uuid'])
|
||||
|
||||
# Get computation time
|
||||
@@ -75,7 +96,6 @@ def test_trip(client, request, sightseeing, shopping, nature, max_time_minute, s
|
||||
log_trip_details(request, landmarks, result['total_time'], prefs.max_time_minute)
|
||||
|
||||
# checks :
|
||||
assert response.status_code == 200 # check for successful planning
|
||||
assert comp_time < 30, f"Computation time exceeded 30 seconds: {comp_time:.2f} seconds"
|
||||
assert prefs.max_time_minute*0.8 < result['total_time'], f"Trip too short: {result['total_time']} instead of {prefs.max_time_minute}"
|
||||
assert prefs.max_time_minute*1.2 > result['total_time'], f"Trip too long: {result['total_time']} instead of {prefs.max_time_minute}"
|
||||
|
||||
Reference in New Issue
Block a user