Spaces:
Sleeping
Sleeping
Initial Deploy
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- Dockerfile +17 -0
- README.md +28 -5
- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-314.pyc +0 -0
- app/__pycache__/deps.cpython-314.pyc +0 -0
- app/__pycache__/main.cpython-314.pyc +0 -0
- app/api/__init__.py +0 -0
- app/api/__pycache__/__init__.cpython-314.pyc +0 -0
- app/api/v1/__init__.py +0 -0
- app/api/v1/__pycache__/__init__.cpython-314.pyc +0 -0
- app/api/v1/__pycache__/auth.cpython-314.pyc +0 -0
- app/api/v1/__pycache__/drivers.cpython-314.pyc +0 -0
- app/api/v1/__pycache__/payments.cpython-314.pyc +0 -0
- app/api/v1/__pycache__/rides.cpython-314.pyc +0 -0
- app/api/v1/__pycache__/users.cpython-314.pyc +0 -0
- app/api/v1/drivers.py +41 -0
- app/api/v1/payments.py +118 -0
- app/api/v1/rides.py +79 -0
- app/api/v1/users.py +20 -0
- app/core/__init__.py +0 -0
- app/core/__pycache__/__init__.cpython-314.pyc +0 -0
- app/core/__pycache__/config.cpython-314.pyc +0 -0
- app/core/__pycache__/database.cpython-314.pyc +0 -0
- app/core/__pycache__/security.cpython-314.pyc +0 -0
- app/core/config.py +20 -0
- app/core/database.py +11 -0
- app/deps.py +77 -0
- app/main.py +24 -0
- app/models/__init__.py +0 -0
- app/models/__pycache__/__init__.cpython-314.pyc +0 -0
- app/models/__pycache__/profile.cpython-314.pyc +0 -0
- app/models/__pycache__/ride.cpython-314.pyc +0 -0
- app/models/__pycache__/user.cpython-314.pyc +0 -0
- app/models/driver.py +16 -0
- app/models/payment.py +0 -0
- app/models/profile.py +19 -0
- app/models/ride.py +35 -0
- app/schemas/__init__.py +0 -0
- app/schemas/__pycache__/__init__.cpython-314.pyc +0 -0
- app/schemas/__pycache__/auth.cpython-314.pyc +0 -0
- app/schemas/__pycache__/drivers.cpython-314.pyc +0 -0
- app/schemas/__pycache__/payments.cpython-314.pyc +0 -0
- app/schemas/__pycache__/rides.cpython-314.pyc +0 -0
- app/schemas/__pycache__/users.cpython-314.pyc +0 -0
- app/schemas/drivers.py +8 -0
- app/schemas/payments.py +28 -0
- app/schemas/rides.py +49 -0
- app/schemas/users.py +5 -0
- app/services/__init__.py +0 -0
- app/services/__pycache__/__init__.cpython-314.pyc +0 -0
Dockerfile
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 6 |
+
build-essential \
|
| 7 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 8 |
+
|
| 9 |
+
COPY requirements.txt .
|
| 10 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
+
|
| 12 |
+
COPY app ./app
|
| 13 |
+
COPY alembic ./alembic
|
| 14 |
+
|
| 15 |
+
EXPOSE 8000
|
| 16 |
+
|
| 17 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,11 +1,34 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
license: apache-2.0
|
|
|
|
| 9 |
---
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: RideShare API
|
| 3 |
+
emoji: 🚕
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: emerald
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
pinned: false
|
| 9 |
license: apache-2.0
|
| 10 |
+
short_description: FastAPI backend for the RideShare demo (rider, driver, payments).
|
| 11 |
---
|
| 12 |
|
| 13 |
+
# RideShare API
|
| 14 |
+
|
| 15 |
+
FastAPI backend powering the RideShare Android demo. PostgreSQL/PostGIS data lives in Supabase; this service exposes the REST + WebSocket surface the mobile app consumes.
|
| 16 |
+
|
| 17 |
+
## Health check
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
GET /health → {"status":"ok"}
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
## Required secrets (set in HF Space → Settings → Secrets)
|
| 24 |
+
|
| 25 |
+
| Key | Source |
|
| 26 |
+
|---|---|
|
| 27 |
+
| `DATABASE_URL` | Supabase Transaction Pooler URI with `postgresql+asyncpg://` prefix |
|
| 28 |
+
| `SUPABASE_URL` | Supabase project URL |
|
| 29 |
+
| `SUPABASE_JWT_SECRET` | Supabase → Settings → API → JWT Settings → JWT Secret |
|
| 30 |
+
| `GOOGLE_MAPS_KEY` | Google Cloud project key with Directions API enabled |
|
| 31 |
+
| `STRIPE_SECRET_KEY` | Stripe dashboard (test mode) |
|
| 32 |
+
| `STRIPE_PUBLISHABLE_KEY` | Stripe dashboard (test mode) |
|
| 33 |
+
|
| 34 |
+
App reads them at runtime via `pydantic-settings`. Missing required vars will crash on boot — check the build logs.
|
app/__init__.py
ADDED
|
File without changes
|
app/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (179 Bytes). View file
|
|
|
app/__pycache__/deps.cpython-314.pyc
ADDED
|
Binary file (4.91 kB). View file
|
|
|
app/__pycache__/main.cpython-314.pyc
ADDED
|
Binary file (1.44 kB). View file
|
|
|
app/api/__init__.py
ADDED
|
File without changes
|
app/api/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (183 Bytes). View file
|
|
|
app/api/v1/__init__.py
ADDED
|
File without changes
|
app/api/v1/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (186 Bytes). View file
|
|
|
app/api/v1/__pycache__/auth.cpython-314.pyc
ADDED
|
Binary file (3.5 kB). View file
|
|
|
app/api/v1/__pycache__/drivers.cpython-314.pyc
ADDED
|
Binary file (3.79 kB). View file
|
|
|
app/api/v1/__pycache__/payments.cpython-314.pyc
ADDED
|
Binary file (6.69 kB). View file
|
|
|
app/api/v1/__pycache__/rides.cpython-314.pyc
ADDED
|
Binary file (4.58 kB). View file
|
|
|
app/api/v1/__pycache__/users.cpython-314.pyc
ADDED
|
Binary file (1.2 kB). View file
|
|
|
app/api/v1/drivers.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
+
from app.deps import get_current_user
|
| 4 |
+
from app.models.profile import Profile, UserRole
|
| 5 |
+
from app.schemas.drivers import LocationIn
|
| 6 |
+
from app.services.redis_client import r
|
| 7 |
+
|
| 8 |
+
router = APIRouter()
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _require_driver(user: Profile) -> None:
|
| 12 |
+
if user.role != UserRole.driver:
|
| 13 |
+
raise HTTPException(403, 'Driver role required')
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post('/online')
|
| 17 |
+
async def go_online(loc: LocationIn, user: Profile = Depends(get_current_user)):
|
| 18 |
+
_require_driver(user)
|
| 19 |
+
await r.geoadd('drivers:online', (loc.lng, loc.lat, str(user.id)))
|
| 20 |
+
await r.hset(f'driver:{user.id}', mapping={'lat': loc.lat, 'lng': loc.lng})
|
| 21 |
+
return {'ok': True}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.post('/offline')
|
| 25 |
+
async def go_offline(user: Profile = Depends(get_current_user)):
|
| 26 |
+
_require_driver(user)
|
| 27 |
+
await r.zrem('drivers:online', str(user.id))
|
| 28 |
+
await r.delete(f'driver:{user.id}')
|
| 29 |
+
return {'ok': True}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@router.post('/location')
|
| 33 |
+
async def update_location(loc: LocationIn, user: Profile = Depends(get_current_user)):
|
| 34 |
+
_require_driver(user)
|
| 35 |
+
await r.geoadd('drivers:online', (loc.lng, loc.lat, str(user.id)))
|
| 36 |
+
if loc.ride_id is not None:
|
| 37 |
+
await r.publish(
|
| 38 |
+
f'ride:{loc.ride_id}:driver_location',
|
| 39 |
+
json.dumps({'lat': loc.lat, 'lng': loc.lng}),
|
| 40 |
+
)
|
| 41 |
+
return {'ok': True}
|
app/api/v1/payments.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import UTC, datetime
|
| 3 |
+
|
| 4 |
+
import stripe
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
|
| 8 |
+
from app.core.config import settings
|
| 9 |
+
from app.core.database import get_db
|
| 10 |
+
from app.deps import get_current_user
|
| 11 |
+
from app.models.profile import Profile
|
| 12 |
+
from app.models.ride import Ride, RideStatus
|
| 13 |
+
from app.schemas.payments import (
|
| 14 |
+
ConfirmRidePaymentIn,
|
| 15 |
+
InitRidePaymentIn,
|
| 16 |
+
InitRidePaymentOut,
|
| 17 |
+
TestChargeIn,
|
| 18 |
+
TestChargeOut,
|
| 19 |
+
)
|
| 20 |
+
from app.services.stripe_service import (
|
| 21 |
+
create_ephemeral_key,
|
| 22 |
+
create_payment_intent,
|
| 23 |
+
ensure_customer,
|
| 24 |
+
)
|
| 25 |
+
from app.websockets.manager import manager
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
router = APIRouter()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.post('/init-ride-payment', response_model=InitRidePaymentOut)
|
| 32 |
+
async def init_ride_payment(
|
| 33 |
+
data: InitRidePaymentIn,
|
| 34 |
+
user: Profile = Depends(get_current_user),
|
| 35 |
+
db: AsyncSession = Depends(get_db),
|
| 36 |
+
):
|
| 37 |
+
ride = await db.get(Ride, data.ride_id)
|
| 38 |
+
if ride is None:
|
| 39 |
+
raise HTTPException(404, 'Ride not found')
|
| 40 |
+
if ride.rider_id != user.id:
|
| 41 |
+
raise HTTPException(403, 'Only the rider can pay for this ride')
|
| 42 |
+
if ride.fare is None or ride.fare <= 0:
|
| 43 |
+
raise HTTPException(400, 'Ride has no fare')
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
customer_id = ensure_customer(user)
|
| 47 |
+
intent = create_payment_intent(
|
| 48 |
+
amount=ride.fare, customer_id=customer_id, ride_id=str(ride.id)
|
| 49 |
+
)
|
| 50 |
+
ephemeral = create_ephemeral_key(customer_id)
|
| 51 |
+
except stripe.StripeError as exc:
|
| 52 |
+
logger.exception('Stripe error initialising ride payment')
|
| 53 |
+
raise HTTPException(502, f'Stripe error: {exc.user_message or str(exc)}') from exc
|
| 54 |
+
|
| 55 |
+
ride.payment_intent_id = intent['id']
|
| 56 |
+
await db.commit()
|
| 57 |
+
|
| 58 |
+
return InitRidePaymentOut(
|
| 59 |
+
payment_intent_id=intent['id'],
|
| 60 |
+
client_secret=intent['client_secret'],
|
| 61 |
+
ephemeral_key=ephemeral,
|
| 62 |
+
customer_id=customer_id,
|
| 63 |
+
publishable_key=settings.STRIPE_PUBLISHABLE_KEY,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@router.post('/confirm-ride-payment')
|
| 68 |
+
async def confirm_ride_payment(
|
| 69 |
+
data: ConfirmRidePaymentIn,
|
| 70 |
+
user: Profile = Depends(get_current_user),
|
| 71 |
+
db: AsyncSession = Depends(get_db),
|
| 72 |
+
):
|
| 73 |
+
ride = await db.get(Ride, data.ride_id)
|
| 74 |
+
if ride is None:
|
| 75 |
+
raise HTTPException(404, 'Ride not found')
|
| 76 |
+
if ride.rider_id != user.id:
|
| 77 |
+
raise HTTPException(403, 'Only the rider can confirm this payment')
|
| 78 |
+
|
| 79 |
+
ride.status = RideStatus.completed
|
| 80 |
+
ride.ended_at = datetime.now(UTC)
|
| 81 |
+
await db.commit()
|
| 82 |
+
|
| 83 |
+
receipt = {
|
| 84 |
+
'type': 'ride_paid',
|
| 85 |
+
'ride_id': str(ride.id),
|
| 86 |
+
'fare': ride.fare,
|
| 87 |
+
}
|
| 88 |
+
await manager.send(str(ride.rider_id), receipt)
|
| 89 |
+
if ride.driver_id is not None:
|
| 90 |
+
await manager.send(str(ride.driver_id), receipt)
|
| 91 |
+
|
| 92 |
+
return {'ok': True}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@router.post('/test-charge', response_model=TestChargeOut)
|
| 96 |
+
async def test_charge(
|
| 97 |
+
data: TestChargeIn,
|
| 98 |
+
user: Profile = Depends(get_current_user),
|
| 99 |
+
db: AsyncSession = Depends(get_db),
|
| 100 |
+
):
|
| 101 |
+
if data.amount <= 0:
|
| 102 |
+
raise HTTPException(400, 'Amount must be greater than zero')
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
customer_id = ensure_customer(user)
|
| 106 |
+
intent = stripe.PaymentIntent.create(
|
| 107 |
+
amount=int(data.amount * 100),
|
| 108 |
+
currency=data.currency,
|
| 109 |
+
customer=customer_id,
|
| 110 |
+
automatic_payment_methods={'enabled': True, 'allow_redirects': 'never'},
|
| 111 |
+
metadata={'profile_id': str(user.id), 'kind': 'test_charge'},
|
| 112 |
+
)
|
| 113 |
+
except stripe.StripeError as exc:
|
| 114 |
+
logger.exception('Stripe error creating test charge')
|
| 115 |
+
raise HTTPException(502, f'Stripe error: {exc.user_message or str(exc)}') from exc
|
| 116 |
+
|
| 117 |
+
await db.commit()
|
| 118 |
+
return TestChargeOut(payment_intent_id=intent['id'], client_secret=intent['client_secret'])
|
app/api/v1/rides.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import UTC, datetime
|
| 2 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 3 |
+
from geoalchemy2 import WKTElement
|
| 4 |
+
from sqlalchemy.exc import SQLAlchemyError
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
|
| 7 |
+
from app.core.database import get_db
|
| 8 |
+
from app.deps import get_current_user
|
| 9 |
+
from app.models.ride import Ride
|
| 10 |
+
from app.services.maps import google_directions
|
| 11 |
+
from app.services.matching import find_nearest_driver
|
| 12 |
+
from app.services.pricing import estimate_fare
|
| 13 |
+
from app.schemas.rides import EstimateIn, EstimateOut, RideRequestIn, RideOut
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.post('/estimate', response_model=EstimateOut)
|
| 19 |
+
async def estimate(data: EstimateIn):
|
| 20 |
+
try:
|
| 21 |
+
distance_km, duration_min = await google_directions(data.pickup, data.dropoff)
|
| 22 |
+
except Exception as exc:
|
| 23 |
+
raise HTTPException(400, 'Unable to estimate this route') from exc
|
| 24 |
+
|
| 25 |
+
return EstimateOut(
|
| 26 |
+
distance_km=distance_km,
|
| 27 |
+
duration_min=duration_min,
|
| 28 |
+
fare=estimate_fare(distance_km, duration_min, data.vehicle_type),
|
| 29 |
+
vehicle_type=data.vehicle_type,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@router.post('/request', response_model=RideOut)
|
| 34 |
+
async def request_ride(
|
| 35 |
+
data: RideRequestIn,
|
| 36 |
+
user=Depends(get_current_user),
|
| 37 |
+
db: AsyncSession = Depends(get_db),
|
| 38 |
+
):
|
| 39 |
+
ride = Ride(
|
| 40 |
+
rider_id=user.id,
|
| 41 |
+
pickup=WKTElement(f'POINT({data.pickup.lng} {data.pickup.lat})', srid=4326),
|
| 42 |
+
dropoff=WKTElement(f'POINT({data.dropoff.lng} {data.dropoff.lat})', srid=4326),
|
| 43 |
+
pickup_address=data.pickup_address,
|
| 44 |
+
dropoff_address=data.dropoff_address,
|
| 45 |
+
fare=data.fare,
|
| 46 |
+
distance_km=data.distance_km,
|
| 47 |
+
duration_min=data.duration_min,
|
| 48 |
+
vehicle_type=data.vehicle_type,
|
| 49 |
+
)
|
| 50 |
+
db.add(ride)
|
| 51 |
+
try:
|
| 52 |
+
await db.commit()
|
| 53 |
+
await db.refresh(ride)
|
| 54 |
+
except SQLAlchemyError as exc:
|
| 55 |
+
await db.rollback()
|
| 56 |
+
raise HTTPException(400, 'Unable to save ride request') from exc
|
| 57 |
+
|
| 58 |
+
try:
|
| 59 |
+
await find_nearest_driver(ride.id, data.pickup)
|
| 60 |
+
except HTTPException as exc:
|
| 61 |
+
if exc.status_code != 404:
|
| 62 |
+
raise
|
| 63 |
+
except Exception:
|
| 64 |
+
# Ride creation should still succeed if live driver matching is temporarily unavailable.
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
return RideOut(
|
| 68 |
+
id=ride.id,
|
| 69 |
+
rider_id=ride.rider_id,
|
| 70 |
+
driver_id=ride.driver_id,
|
| 71 |
+
status=ride.status,
|
| 72 |
+
fare=ride.fare,
|
| 73 |
+
distance_km=ride.distance_km,
|
| 74 |
+
duration_min=ride.duration_min,
|
| 75 |
+
pickup_address=ride.pickup_address,
|
| 76 |
+
dropoff_address=ride.dropoff_address,
|
| 77 |
+
vehicle_type=ride.vehicle_type,
|
| 78 |
+
requested_at=ride.requested_at or datetime.now(UTC),
|
| 79 |
+
)
|
app/api/v1/users.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
|
| 4 |
+
from app.core.database import get_db
|
| 5 |
+
from app.deps import get_current_user
|
| 6 |
+
from app.models.profile import Profile
|
| 7 |
+
from app.schemas.users import PushTokenIn
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@router.post('/push-token')
|
| 13 |
+
async def save_push_token(
|
| 14 |
+
data: PushTokenIn,
|
| 15 |
+
user: Profile = Depends(get_current_user),
|
| 16 |
+
db: AsyncSession = Depends(get_db),
|
| 17 |
+
):
|
| 18 |
+
user.push_token = data.token
|
| 19 |
+
await db.commit()
|
| 20 |
+
return {'ok': True}
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (184 Bytes). View file
|
|
|
app/core/__pycache__/config.cpython-314.pyc
ADDED
|
Binary file (1.35 kB). View file
|
|
|
app/core/__pycache__/database.cpython-314.pyc
ADDED
|
Binary file (991 Bytes). View file
|
|
|
app/core/__pycache__/security.cpython-314.pyc
ADDED
|
Binary file (2.62 kB). View file
|
|
|
app/core/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic_settings import BaseSettings
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Settings(BaseSettings):
|
| 5 |
+
DATABASE_URL: str
|
| 6 |
+
REDIS_URL: str = 'redis://localhost:6379/0'
|
| 7 |
+
|
| 8 |
+
SUPABASE_URL: str
|
| 9 |
+
SUPABASE_JWT_SECRET: str
|
| 10 |
+
SUPABASE_JWT_ALGORITHM: str = 'HS256'
|
| 11 |
+
|
| 12 |
+
GOOGLE_MAPS_KEY: str
|
| 13 |
+
STRIPE_SECRET_KEY: str
|
| 14 |
+
STRIPE_PUBLISHABLE_KEY: str
|
| 15 |
+
|
| 16 |
+
class Config:
|
| 17 |
+
env_file = '.env'
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
settings = Settings()
|
app/core/database.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
|
| 2 |
+
from sqlalchemy.orm import sessionmaker, declarative_base
|
| 3 |
+
from app.core.config import settings
|
| 4 |
+
|
| 5 |
+
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
| 6 |
+
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
| 7 |
+
Base = declarative_base()
|
| 8 |
+
|
| 9 |
+
async def get_db():
|
| 10 |
+
async with AsyncSessionLocal() as session:
|
| 11 |
+
yield session
|
app/deps.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from fastapi import Depends, HTTPException
|
| 3 |
+
from fastapi.security import OAuth2PasswordBearer
|
| 4 |
+
from jose import jwt, JWTError
|
| 5 |
+
import requests
|
| 6 |
+
from sqlalchemy.exc import SQLAlchemyError
|
| 7 |
+
from app.core.config import settings
|
| 8 |
+
from app.core.database import get_db
|
| 9 |
+
from app.models.profile import Profile, UserRole
|
| 10 |
+
|
| 11 |
+
oauth2 = OAuth2PasswordBearer(tokenUrl='supabase')
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def verify_supabase_token(token: str = Depends(oauth2)) -> dict:
|
| 15 |
+
try:
|
| 16 |
+
header = jwt.get_unverified_header(token)
|
| 17 |
+
algorithm = header.get('alg')
|
| 18 |
+
if not algorithm:
|
| 19 |
+
raise JWTError('Missing token algorithm')
|
| 20 |
+
|
| 21 |
+
if algorithm == settings.SUPABASE_JWT_ALGORITHM:
|
| 22 |
+
payload = jwt.decode(
|
| 23 |
+
token,
|
| 24 |
+
settings.SUPABASE_JWT_SECRET,
|
| 25 |
+
algorithms=[settings.SUPABASE_JWT_ALGORITHM],
|
| 26 |
+
audience='authenticated',
|
| 27 |
+
)
|
| 28 |
+
else:
|
| 29 |
+
jwks_url = f'{settings.SUPABASE_URL.rstrip("/")}/auth/v1/.well-known/jwks.json'
|
| 30 |
+
response = requests.get(jwks_url, timeout=5)
|
| 31 |
+
response.raise_for_status()
|
| 32 |
+
keys = response.json().get('keys', [])
|
| 33 |
+
key = next((item for item in keys if item.get('kid') == header.get('kid')), None)
|
| 34 |
+
if not key:
|
| 35 |
+
raise JWTError('No matching JWKS key')
|
| 36 |
+
payload = jwt.decode(
|
| 37 |
+
token,
|
| 38 |
+
key,
|
| 39 |
+
algorithms=[algorithm],
|
| 40 |
+
audience='authenticated',
|
| 41 |
+
issuer=f'{settings.SUPABASE_URL.rstrip("/")}/auth/v1',
|
| 42 |
+
)
|
| 43 |
+
uuid.UUID(payload['sub'])
|
| 44 |
+
return payload
|
| 45 |
+
except (JWTError, KeyError, ValueError, requests.RequestException):
|
| 46 |
+
raise HTTPException(401, 'Invalid token')
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
async def get_current_user(payload: dict = Depends(verify_supabase_token), db=Depends(get_db)) -> Profile:
|
| 50 |
+
user_id = uuid.UUID(payload['sub'])
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
profile = await db.get(Profile, user_id)
|
| 54 |
+
except SQLAlchemyError as exc:
|
| 55 |
+
raise HTTPException(503, 'Unable to load user profile') from exc
|
| 56 |
+
|
| 57 |
+
if not profile:
|
| 58 |
+
metadata = payload.get('user_metadata') or {}
|
| 59 |
+
try:
|
| 60 |
+
role = UserRole(metadata.get('role'))
|
| 61 |
+
except ValueError:
|
| 62 |
+
role = UserRole.rider
|
| 63 |
+
|
| 64 |
+
profile = Profile(
|
| 65 |
+
id=user_id,
|
| 66 |
+
full_name=metadata.get('full_name') or payload.get('email') or 'Rider',
|
| 67 |
+
role=role,
|
| 68 |
+
)
|
| 69 |
+
db.add(profile)
|
| 70 |
+
try:
|
| 71 |
+
await db.commit()
|
| 72 |
+
await db.refresh(profile)
|
| 73 |
+
except SQLAlchemyError as exc:
|
| 74 |
+
await db.rollback()
|
| 75 |
+
raise HTTPException(503, 'Unable to create user profile') from exc
|
| 76 |
+
|
| 77 |
+
return profile
|
app/main.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from app.api.v1 import rides, drivers, payments, users
|
| 4 |
+
from app.websockets import routes as ws_routes
|
| 5 |
+
|
| 6 |
+
app = FastAPI(title='RideShare API', version='1.0.0')
|
| 7 |
+
|
| 8 |
+
app.add_middleware(
|
| 9 |
+
CORSMiddleware,
|
| 10 |
+
allow_origins=['*'],
|
| 11 |
+
allow_methods=['*'],
|
| 12 |
+
allow_headers=['*'],
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
app.include_router(rides.router, prefix='/api/v1/rides', tags=['rides'])
|
| 16 |
+
app.include_router(drivers.router, prefix='/api/v1/drivers', tags=['drivers'])
|
| 17 |
+
app.include_router(payments.router, prefix='/api/v1/payments', tags=['payments'])
|
| 18 |
+
app.include_router(users.router, prefix='/api/v1/users', tags=['users'])
|
| 19 |
+
app.include_router(ws_routes.router)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@app.get('/health')
|
| 23 |
+
async def health():
|
| 24 |
+
return {'status': 'ok'}
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (186 Bytes). View file
|
|
|
app/models/__pycache__/profile.cpython-314.pyc
ADDED
|
Binary file (1.42 kB). View file
|
|
|
app/models/__pycache__/ride.cpython-314.pyc
ADDED
|
Binary file (2.39 kB). View file
|
|
|
app/models/__pycache__/user.cpython-314.pyc
ADDED
|
Binary file (1.49 kB). View file
|
|
|
app/models/driver.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import Column, ForeignKey, Boolean, Float, String
|
| 2 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 3 |
+
from geoalchemy2 import Geography
|
| 4 |
+
from app.core.database import Base
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Driver(Base):
|
| 8 |
+
__tablename__ = 'drivers'
|
| 9 |
+
user_id = Column(UUID(as_uuid=True), ForeignKey('profiles.id', ondelete='CASCADE'), primary_key=True)
|
| 10 |
+
license_no = Column(String, nullable=False)
|
| 11 |
+
vehicle_make = Column(String)
|
| 12 |
+
vehicle_model = Column(String)
|
| 13 |
+
vehicle_plate = Column(String)
|
| 14 |
+
is_online = Column(Boolean, default=False)
|
| 15 |
+
rating = Column(Float, default=5.0)
|
| 16 |
+
current_location = Column(Geography('POINT', srid=4326))
|
app/models/payment.py
ADDED
|
File without changes
|
app/models/profile.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import enum
|
| 2 |
+
from sqlalchemy import Column, String, DateTime, Enum, func
|
| 3 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 4 |
+
from app.core.database import Base
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class UserRole(str, enum.Enum):
|
| 8 |
+
rider = 'rider'
|
| 9 |
+
driver = 'driver'
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Profile(Base):
|
| 13 |
+
__tablename__ = 'profiles'
|
| 14 |
+
id = Column(UUID(as_uuid=True), primary_key=True)
|
| 15 |
+
full_name = Column(String, nullable=False)
|
| 16 |
+
role = Column(Enum(UserRole, name='user_role'), nullable=False)
|
| 17 |
+
stripe_customer_id = Column(String, nullable=True)
|
| 18 |
+
push_token = Column(String, nullable=True)
|
| 19 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
app/models/ride.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
import enum
|
| 3 |
+
from sqlalchemy import Column, ForeignKey, Float, String, DateTime, Enum, func
|
| 4 |
+
from sqlalchemy.dialects.postgresql import UUID
|
| 5 |
+
from geoalchemy2 import Geography
|
| 6 |
+
from app.core.database import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class RideStatus(str, enum.Enum):
|
| 10 |
+
searching = 'searching'
|
| 11 |
+
accepted = 'accepted'
|
| 12 |
+
arriving = 'arriving'
|
| 13 |
+
in_progress = 'in_progress'
|
| 14 |
+
completed = 'completed'
|
| 15 |
+
cancelled = 'cancelled'
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Ride(Base):
|
| 19 |
+
__tablename__ = 'rides'
|
| 20 |
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
| 21 |
+
rider_id = Column(UUID(as_uuid=True), ForeignKey('profiles.id', ondelete='CASCADE'), nullable=False)
|
| 22 |
+
driver_id = Column(UUID(as_uuid=True), ForeignKey('profiles.id', ondelete='SET NULL'), nullable=True)
|
| 23 |
+
pickup = Column(Geography('POINT', srid=4326))
|
| 24 |
+
dropoff = Column(Geography('POINT', srid=4326))
|
| 25 |
+
pickup_address = Column(String)
|
| 26 |
+
dropoff_address = Column(String)
|
| 27 |
+
status = Column(Enum(RideStatus, name='ride_status'), default=RideStatus.searching, nullable=False)
|
| 28 |
+
fare = Column(Float)
|
| 29 |
+
distance_km = Column(Float)
|
| 30 |
+
duration_min = Column(Float)
|
| 31 |
+
payment_intent_id = Column(String, nullable=True)
|
| 32 |
+
vehicle_type = Column(String, default='car')
|
| 33 |
+
requested_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 34 |
+
started_at = Column(DateTime(timezone=True), nullable=True)
|
| 35 |
+
ended_at = Column(DateTime(timezone=True), nullable=True)
|
app/schemas/__init__.py
ADDED
|
File without changes
|
app/schemas/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (187 Bytes). View file
|
|
|
app/schemas/__pycache__/auth.cpython-314.pyc
ADDED
|
Binary file (1.86 kB). View file
|
|
|
app/schemas/__pycache__/drivers.cpython-314.pyc
ADDED
|
Binary file (874 Bytes). View file
|
|
|
app/schemas/__pycache__/payments.cpython-314.pyc
ADDED
|
Binary file (2.51 kB). View file
|
|
|
app/schemas/__pycache__/rides.cpython-314.pyc
ADDED
|
Binary file (3.56 kB). View file
|
|
|
app/schemas/__pycache__/users.cpython-314.pyc
ADDED
|
Binary file (720 Bytes). View file
|
|
|
app/schemas/drivers.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class LocationIn(BaseModel):
|
| 6 |
+
lat: float
|
| 7 |
+
lng: float
|
| 8 |
+
ride_id: uuid.UUID | None = None
|
app/schemas/payments.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class InitRidePaymentIn(BaseModel):
|
| 6 |
+
ride_id: uuid.UUID
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class InitRidePaymentOut(BaseModel):
|
| 10 |
+
payment_intent_id: str
|
| 11 |
+
client_secret: str
|
| 12 |
+
ephemeral_key: str
|
| 13 |
+
customer_id: str
|
| 14 |
+
publishable_key: str
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ConfirmRidePaymentIn(BaseModel):
|
| 18 |
+
ride_id: uuid.UUID
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class TestChargeIn(BaseModel):
|
| 22 |
+
amount: float
|
| 23 |
+
currency: str = 'pkr'
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class TestChargeOut(BaseModel):
|
| 27 |
+
payment_intent_id: str
|
| 28 |
+
client_secret: str
|
app/schemas/rides.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import uuid
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from pydantic import BaseModel, ConfigDict
|
| 4 |
+
from app.models.ride import RideStatus
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Coordinate(BaseModel):
|
| 8 |
+
lat: float
|
| 9 |
+
lng: float
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EstimateIn(BaseModel):
|
| 13 |
+
pickup: Coordinate
|
| 14 |
+
dropoff: Coordinate
|
| 15 |
+
vehicle_type: str = 'car'
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class EstimateOut(BaseModel):
|
| 19 |
+
distance_km: float
|
| 20 |
+
duration_min: float
|
| 21 |
+
fare: float
|
| 22 |
+
vehicle_type: str = 'car'
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class RideRequestIn(BaseModel):
|
| 26 |
+
pickup: Coordinate
|
| 27 |
+
dropoff: Coordinate
|
| 28 |
+
pickup_address: str
|
| 29 |
+
dropoff_address: str
|
| 30 |
+
fare: float
|
| 31 |
+
distance_km: float
|
| 32 |
+
duration_min: float
|
| 33 |
+
vehicle_type: str = 'car'
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class RideOut(BaseModel):
|
| 37 |
+
model_config = ConfigDict(from_attributes=True)
|
| 38 |
+
|
| 39 |
+
id: uuid.UUID
|
| 40 |
+
rider_id: uuid.UUID
|
| 41 |
+
driver_id: uuid.UUID | None = None
|
| 42 |
+
status: RideStatus
|
| 43 |
+
fare: float | None = None
|
| 44 |
+
distance_km: float | None = None
|
| 45 |
+
duration_min: float | None = None
|
| 46 |
+
pickup_address: str | None = None
|
| 47 |
+
dropoff_address: str | None = None
|
| 48 |
+
vehicle_type: str | None = None
|
| 49 |
+
requested_at: datetime
|
app/schemas/users.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class PushTokenIn(BaseModel):
|
| 5 |
+
token: str
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (188 Bytes). View file
|
|
|