MotorSignal-App/backend/motorsignal_api/app/main.py

1064 lines
43 KiB
Python

import uuid
import logging
import os
import smtplib
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
from email_validator import EmailNotValidError, validate_email
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session as DbSession
from auth import (
create_access_token,
create_refresh_token,
create_one_time_token,
decode_access_token,
hash_password,
hash_refresh_token,
hash_one_time_token,
normalize_email,
refresh_expiry,
verify_password,
)
from db import check_database, engine
from models import AuditEvent, AuthRateLimit, EmailVerificationToken, Entitlement, PasswordResetToken, Ride, Session, Subscription, TelemetryPoint, User, Vehicle
logger = logging.getLogger(__name__)
app = FastAPI(
title="MotorSignal API",
version="1.0.0",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
class RegisterRequest(BaseModel):
email: str
password: str = Field(min_length=12, max_length=128)
device_name: str | None = Field(default=None, max_length=255)
class LoginRequest(BaseModel):
email: str
password: str
device_name: str | None = Field(default=None, max_length=255)
class RefreshRequest(BaseModel):
refresh_token: str
class EmailRequest(BaseModel):
email: str
class TokenRequest(BaseModel):
token: str = Field(min_length=20, max_length=256)
class PasswordResetRequest(TokenRequest):
password: str = Field(min_length=12, max_length=128)
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class SessionResponse(BaseModel):
id: uuid.UUID
device_name: str | None
created_at: datetime
expires_at: datetime
last_used_at: datetime | None
current: bool
class AdminUserCreate(BaseModel):
email: str
password: str = Field(min_length=12, max_length=128)
role: str = "user"
class AdminUserUpdate(BaseModel):
email: str | None = None
role: str | None = None
is_active: bool | None = None
email_verified: bool | None = None
class VehicleCreate(BaseModel):
user_id: uuid.UUID
nickname: str = Field(min_length=1, max_length=120)
make: str = Field(min_length=1, max_length=120)
model: str = Field(min_length=1, max_length=120)
year: int | None = Field(default=None, ge=1885, le=2100)
vin: str | None = Field(default=None, min_length=11, max_length=17)
class VehicleUpdate(BaseModel):
nickname: str | None = Field(default=None, min_length=1, max_length=120)
make: str | None = Field(default=None, min_length=1, max_length=120)
model: str | None = Field(default=None, min_length=1, max_length=120)
year: int | None = Field(default=None, ge=1885, le=2100)
vin: str | None = Field(default=None, min_length=11, max_length=17)
class RideCreate(BaseModel):
user_id: uuid.UUID
vehicle_id: uuid.UUID | None = None
started_at: datetime
ended_at: datetime | None = None
distance_miles: float | None = Field(default=None, ge=0)
top_speed_mph: float | None = Field(default=None, ge=0)
avg_speed_mph: float | None = Field(default=None, ge=0)
status: str = Field(default="completed", min_length=1, max_length=32)
class RideUpdate(BaseModel):
vehicle_id: uuid.UUID | None = None
ended_at: datetime | None = None
distance_miles: float | None = Field(default=None, ge=0)
top_speed_mph: float | None = Field(default=None, ge=0)
avg_speed_mph: float | None = Field(default=None, ge=0)
status: str | None = Field(default=None, min_length=1, max_length=32)
class UserVehicleCreate(BaseModel):
nickname: str = Field(min_length=1, max_length=120)
make: str = Field(min_length=1, max_length=120)
model: str = Field(min_length=1, max_length=120)
year: int | None = Field(default=None, ge=1885, le=2100)
vin: str | None = Field(default=None, min_length=11, max_length=17)
class UserRideCreate(BaseModel):
vehicle_id: uuid.UUID | None = None
started_at: datetime
ended_at: datetime | None = None
distance_miles: float | None = Field(default=None, ge=0)
top_speed_mph: float | None = Field(default=None, ge=0)
avg_speed_mph: float | None = Field(default=None, ge=0)
status: str = Field(default="completed", min_length=1, max_length=32)
class TelemetryPointInput(BaseModel):
recorded_at: datetime
latitude: float | None = Field(default=None, ge=-90, le=90)
longitude: float | None = Field(default=None, ge=-180, le=180)
speed_mph: float | None = Field(default=None, ge=0)
lean_angle_deg: float | None = Field(default=None, ge=-90, le=90)
g_force: float | None = Field(default=None, ge=0)
accel_g: float | None = Field(default=None, ge=-10, le=10)
brake_g: float | None = Field(default=None, ge=-10, le=10)
class TelemetryBatch(BaseModel):
points: list[TelemetryPointInput] = Field(min_length=1, max_length=1000)
class SubscriptionUpdate(BaseModel):
plan: str = Field(min_length=1, max_length=32)
status: str = Field(min_length=1, max_length=32)
current_period_start: datetime | None = None
current_period_end: datetime | None = None
class EntitlementCreate(BaseModel):
key: str = Field(min_length=1, max_length=96)
value: dict | None = None
expires_at: datetime | None = None
def get_db():
db = DbSession(engine)
try:
yield db
finally:
db.close()
def enforce_auth_rate_limit(request: Request, db: DbSession, action: str, limit: int = 10, window_seconds: int = 900) -> None:
client = request.client.host if request.client else "unknown"
key = f"{action}:{client}"
now = datetime.now(timezone.utc)
bucket = db.get(AuthRateLimit, key, with_for_update=True)
if bucket is None:
db.add(AuthRateLimit(key=key, window_started=now, attempts=1))
db.commit()
return
elapsed = (now - bucket.window_started).total_seconds()
if elapsed >= window_seconds:
bucket.window_started = now
bucket.attempts = 1
elif bucket.attempts >= limit:
retry_after = max(1, int(window_seconds - elapsed))
raise HTTPException(status_code=429, detail="Too many authentication attempts", headers={"Retry-After": str(retry_after)})
else:
bucket.attempts += 1
db.commit()
def extract_bearer_token(authorization: str | None) -> str:
if not authorization:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing authorization token",
)
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authorization header",
)
return token
def get_request_context(request: Request) -> tuple[str | None, str | None]:
return request.client.host if request.client else None, request.headers.get("user-agent")
def audit(
db: DbSession,
event_type: str,
actor_user_id: uuid.UUID | None = None,
target_user_id: uuid.UUID | None = None,
request: Request | None = None,
metadata: dict | None = None,
) -> None:
ip_address = request.client.host if request and request.client else None
user_agent = request.headers.get("user-agent")[:512] if request and request.headers.get("user-agent") else None
db.add(AuditEvent(event_type=event_type, actor_user_id=actor_user_id, target_user_id=target_user_id, ip_address=ip_address, user_agent=user_agent, metadata_json=metadata))
def send_email(recipient: str, subject: str, body: str) -> None:
host = os.getenv("SMTP_HOST")
if not host:
return
try:
message = EmailMessage()
message["From"] = os.environ["SMTP_FROM"]
message["To"] = recipient
message["Subject"] = subject
message.set_content(body)
port = int(os.getenv("SMTP_PORT", "587"))
with smtplib.SMTP(host, port, timeout=10) as smtp:
if os.getenv("SMTP_STARTTLS", "true").lower() == "true":
smtp.starttls()
if os.getenv("SMTP_USER"):
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"])
smtp.send_message(message)
except (KeyError, OSError, smtplib.SMTPException):
logger.exception("MotorSignal email delivery failed")
def get_current_identity(
authorization: str | None = Header(default=None),
db: DbSession = Depends(get_db),
) -> tuple[User, Session]:
token = extract_bearer_token(authorization)
try:
payload = decode_access_token(token)
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired access token",
)
if payload.get("type") != "access":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token",
)
try:
user_id = uuid.UUID(payload["sub"])
session_id = uuid.UUID(payload["sid"])
except Exception:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid access token",
)
session = db.get(Session, session_id)
if (
session is None
or session.user_id != user_id
or session.revoked_at is not None
or session.expires_at <= datetime.now(timezone.utc)
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session is no longer valid",
)
user = db.get(User, user_id)
if user is None or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is unavailable",
)
return user, session
def get_current_user(identity: tuple[User, Session] = Depends(get_current_identity)) -> User:
return identity[0]
def require_roles(*roles: str):
def dependency(user: User = Depends(get_current_user)) -> User:
if user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return user
return dependency
@app.get("/api/v1/health")
def health():
return {
"status": "ok",
"service": "motorsignal-api",
}
@app.get("/api/v1/ready")
def readiness():
try:
check_database()
return {
"status": "ready",
"database": "ok",
}
except Exception:
raise HTTPException(
status_code=503,
detail="Service unavailable",
)
@app.post(
"/api/v1/auth/register",
response_model=TokenResponse,
status_code=201,
)
def register(
request: RegisterRequest,
http_request: Request,
db: DbSession = Depends(get_db),
):
enforce_auth_rate_limit(http_request, db, "register")
try:
validated = validate_email(
request.email,
check_deliverability=False,
)
email = normalize_email(validated.normalized)
except EmailNotValidError:
raise HTTPException(
status_code=400,
detail="Invalid email address",
)
existing = db.scalar(
select(User).where(User.email == email)
)
if existing is not None:
raise HTTPException(
status_code=409,
detail="Account already exists",
)
user = User(
email=email,
password_hash=hash_password(request.password),
)
db.add(user)
db.flush()
raw_refresh, refresh_hash = create_refresh_token()
session = Session(
user_id=user.id,
refresh_token_hash=refresh_hash,
device_name=request.device_name,
expires_at=refresh_expiry(),
)
db.add(session)
verification_raw, verification_hash = create_one_time_token()
db.add(EmailVerificationToken(user_id=user.id, token_hash=verification_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24)))
audit(db, "auth.register", actor_user_id=user.id, target_user_id=user.id)
db.commit()
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={verification_raw}")
return TokenResponse(
access_token=create_access_token(user.id, session.id),
refresh_token=raw_refresh,
)
@app.post(
"/api/v1/auth/login",
response_model=TokenResponse,
)
def login(
request: LoginRequest,
http_request: Request,
db: DbSession = Depends(get_db),
):
enforce_auth_rate_limit(http_request, db, "login")
email = normalize_email(request.email)
user = db.scalar(
select(User).where(User.email == email)
)
if user is None or not verify_password(
request.password,
user.password_hash,
):
raise HTTPException(
status_code=401,
detail="Invalid email or password",
)
if not user.is_active:
raise HTTPException(
status_code=403,
detail="Account disabled",
)
raw_refresh, refresh_hash = create_refresh_token()
session = Session(
user_id=user.id,
refresh_token_hash=refresh_hash,
device_name=request.device_name,
expires_at=refresh_expiry(),
)
db.add(session)
audit(db, "auth.login", actor_user_id=user.id, target_user_id=user.id)
db.commit()
return TokenResponse(
access_token=create_access_token(user.id, session.id),
refresh_token=raw_refresh,
)
@app.post(
"/api/v1/auth/refresh",
response_model=TokenResponse,
)
def refresh(
request: RefreshRequest,
db: DbSession = Depends(get_db),
):
old_hash = hash_refresh_token(request.refresh_token)
session = db.scalar(
select(Session).where(
Session.refresh_token_hash == old_hash
)
)
now = datetime.now(timezone.utc)
if (
session is None
or session.revoked_at is not None
or session.expires_at <= now
):
raise HTTPException(
status_code=401,
detail="Invalid refresh token",
)
user = db.get(User, session.user_id)
if user is None or not user.is_active:
raise HTTPException(
status_code=401,
detail="Invalid refresh token",
)
new_raw, new_hash = create_refresh_token()
session.refresh_token_hash = new_hash
session.last_used_at = now
session.expires_at = refresh_expiry()
db.commit()
return TokenResponse(
access_token=create_access_token(user.id, session.id),
refresh_token=new_raw,
)
@app.post("/api/v1/auth/logout", status_code=204)
def logout(
request: RefreshRequest,
db: DbSession = Depends(get_db),
):
refresh_hash = hash_refresh_token(request.refresh_token)
session = db.scalar(
select(Session).where(
Session.refresh_token_hash == refresh_hash
)
)
if session is not None and session.revoked_at is None:
session.revoked_at = datetime.now(timezone.utc)
audit(db, "auth.logout", actor_user_id=session.user_id, target_user_id=session.user_id)
db.commit()
return None
@app.get("/api/v1/me")
def me(user: User = Depends(get_current_user)):
return {
"id": str(user.id),
"email": user.email,
"email_verified": user.email_verified,
"created_at": user.created_at,
}
@app.post("/api/v1/auth/verify-email/request", status_code=202)
def request_email_verification(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)):
enforce_auth_rate_limit(http_request, db, "verify-email-request")
email = normalize_email(payload.email)
user = db.scalar(select(User).where(User.email == email))
if user is not None and not user.email_verified and user.is_active:
raw, token_hash = create_one_time_token()
db.add(EmailVerificationToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24)))
audit(db, "auth.email_verification_requested", target_user_id=user.id)
db.commit()
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={raw}")
return {"detail": "If the account can be verified, instructions have been sent."}
@app.post("/api/v1/auth/verify-email", status_code=204)
def verify_email(request: TokenRequest, http_request: Request, db: DbSession = Depends(get_db)):
enforce_auth_rate_limit(http_request, db, "verify-email")
token = db.scalar(select(EmailVerificationToken).where(EmailVerificationToken.token_hash == hash_one_time_token(request.token)))
now = datetime.now(timezone.utc)
if token is None or token.used_at is not None or token.expires_at <= now:
raise HTTPException(status_code=400, detail="Invalid or expired verification token")
user = db.get(User, token.user_id)
if user is None:
raise HTTPException(status_code=400, detail="Invalid verification token")
user.email_verified = True
token.used_at = now
audit(db, "auth.email_verified", actor_user_id=user.id, target_user_id=user.id)
db.commit()
return None
@app.post("/api/v1/auth/password-reset/request", status_code=202)
def request_password_reset(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)):
enforce_auth_rate_limit(http_request, db, "password-reset-request")
email = normalize_email(payload.email)
user = db.scalar(select(User).where(User.email == email))
if user is not None and user.is_active:
raw, token_hash = create_one_time_token()
db.add(PasswordResetToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=1)))
audit(db, "auth.password_reset_requested", target_user_id=user.id)
db.commit()
web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app")
send_email(email, "Reset your MotorSignal password", f"Reset your password: {web_base}/forgot-password?token={raw}")
return {"detail": "If the account exists, reset instructions have been sent."}
@app.post("/api/v1/auth/password-reset", status_code=204)
def reset_password(request: PasswordResetRequest, http_request: Request, db: DbSession = Depends(get_db)):
enforce_auth_rate_limit(http_request, db, "password-reset")
token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_one_time_token(request.token)))
now = datetime.now(timezone.utc)
if token is None or token.used_at is not None or token.expires_at <= now:
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
user = db.get(User, token.user_id)
if user is None or not user.is_active:
raise HTTPException(status_code=400, detail="Invalid reset token")
user.password_hash = hash_password(request.password)
token.used_at = now
db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False)
audit(db, "auth.password_reset_completed", actor_user_id=user.id, target_user_id=user.id)
db.commit()
return None
@app.get("/api/v1/auth/sessions", response_model=list[SessionResponse])
def list_sessions(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)):
user, current = identity
sessions = db.scalars(select(Session).where(Session.user_id == user.id, Session.revoked_at.is_(None)).order_by(Session.created_at.desc())).all()
return [SessionResponse(id=item.id, device_name=item.device_name, created_at=item.created_at, expires_at=item.expires_at, last_used_at=item.last_used_at, current=item.id == current.id) for item in sessions]
@app.delete("/api/v1/auth/sessions/{session_id}", status_code=204)
def revoke_session(session_id: uuid.UUID, identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)):
user, _ = identity
session = db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id))
if session is None:
raise HTTPException(status_code=404, detail="Session not found")
if session.revoked_at is None:
session.revoked_at = datetime.now(timezone.utc)
audit(db, "auth.session_revoked", actor_user_id=user.id, target_user_id=user.id, metadata={"session_id": str(session.id)})
db.commit()
return None
@app.post("/api/v1/auth/logout-all", status_code=204)
def logout_all(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)):
user, _ = identity
now = datetime.now(timezone.utc)
db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False)
audit(db, "auth.logout_all", actor_user_id=user.id, target_user_id=user.id)
db.commit()
return None
@app.get("/api/v1/admin/audit-events")
def list_audit_events(user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
events = db.scalars(select(AuditEvent).order_by(AuditEvent.created_at.desc()).limit(100)).all()
return [{"id": str(event.id), "event_type": event.event_type, "actor_user_id": str(event.actor_user_id) if event.actor_user_id else None, "target_user_id": str(event.target_user_id) if event.target_user_id else None, "created_at": event.created_at, "metadata": event.metadata_json} for event in events]
def user_payload(user: User) -> dict:
return {"id": str(user.id), "email": user.email, "role": user.role, "is_active": user.is_active, "email_verified": user.email_verified, "created_at": user.created_at}
def vehicle_payload(vehicle: Vehicle) -> dict:
return {"id": str(vehicle.id), "user_id": str(vehicle.user_id), "nickname": vehicle.nickname, "make": vehicle.make, "model": vehicle.model, "year": vehicle.year, "vin": vehicle.vin, "created_at": vehicle.created_at, "updated_at": vehicle.updated_at}
def ride_payload(ride: Ride) -> dict:
return {"id": str(ride.id), "user_id": str(ride.user_id), "vehicle_id": str(ride.vehicle_id) if ride.vehicle_id else None, "started_at": ride.started_at, "ended_at": ride.ended_at, "distance_miles": ride.distance_miles, "top_speed_mph": ride.top_speed_mph, "avg_speed_mph": ride.avg_speed_mph, "status": ride.status, "created_at": ride.created_at}
def validate_role(role: str) -> str:
if role not in {"user", "support", "admin"}:
raise HTTPException(status_code=400, detail="Invalid role")
return role
@app.get("/api/v1/admin/users")
def list_users(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), search: str | None = None, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
query = select(User)
count_query = select(func.count()).select_from(User)
if search:
pattern = f"%{search.strip().lower()}%"
condition = or_(func.lower(User.email).like(pattern), User.role == search.strip().lower())
query = query.where(condition)
count_query = count_query.where(condition)
total = db.scalar(count_query) or 0
users = db.scalars(query.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
return {"items": [user_payload(item) for item in users], "page": page, "page_size": page_size, "total": total}
@app.post("/api/v1/admin/users", status_code=201)
def create_user(request: AdminUserCreate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
email = normalize_email(request.email)
try:
email = normalize_email(validate_email(email, check_deliverability=False).normalized)
except EmailNotValidError:
raise HTTPException(status_code=400, detail="Invalid email address")
if db.scalar(select(User).where(User.email == email)) is not None:
raise HTTPException(status_code=409, detail="Account already exists")
user = User(email=email, password_hash=hash_password(request.password), role=validate_role(request.role))
db.add(user)
db.flush()
audit(db, "admin.user_created", actor_user_id=actor.id, target_user_id=user.id)
db.commit()
db.refresh(user)
return user_payload(user)
@app.get("/api/v1/admin/users/{user_id}")
def get_user(user_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
return user_payload(target)
@app.patch("/api/v1/admin/users/{user_id}")
def update_user(user_id: uuid.UUID, request: AdminUserUpdate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
changes = request.model_dump(exclude_unset=True)
if "email" in changes:
try:
changes["email"] = normalize_email(validate_email(changes["email"], check_deliverability=False).normalized)
except EmailNotValidError:
raise HTTPException(status_code=400, detail="Invalid email address")
duplicate = db.scalar(select(User).where(User.email == changes["email"], User.id != target.id))
if duplicate is not None:
raise HTTPException(status_code=409, detail="Account already exists")
if "role" in changes:
changes["role"] = validate_role(changes["role"])
for field, value in changes.items():
setattr(target, field, value)
audit(db, "admin.user_updated", actor_user_id=actor.id, target_user_id=target.id, metadata={"fields": list(changes)})
db.commit()
db.refresh(target)
return user_payload(target)
@app.delete("/api/v1/admin/users/{user_id}", status_code=204)
def deactivate_user(user_id: uuid.UUID, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
target = db.get(User, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
target.is_active = False
now = datetime.now(timezone.utc)
db.query(Session).filter(Session.user_id == target.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False)
audit(db, "admin.user_deactivated", actor_user_id=actor.id, target_user_id=target.id)
db.commit()
return None
@app.get("/api/v1/admin/vehicles")
def list_vehicles(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user_id: uuid.UUID | None = None, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
query = select(Vehicle)
count_query = select(func.count()).select_from(Vehicle)
if user_id:
query = query.where(Vehicle.user_id == user_id)
count_query = count_query.where(Vehicle.user_id == user_id)
total = db.scalar(count_query) or 0
vehicles = db.scalars(query.order_by(Vehicle.created_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
return {"items": [vehicle_payload(item) for item in vehicles], "page": page, "page_size": page_size, "total": total}
@app.post("/api/v1/admin/vehicles", status_code=201)
def create_vehicle(request: VehicleCreate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
if db.get(User, request.user_id) is None:
raise HTTPException(status_code=404, detail="User not found")
vehicle = Vehicle(**request.model_dump())
db.add(vehicle)
db.flush()
audit(db, "admin.vehicle_created", actor_user_id=actor.id, target_user_id=vehicle.user_id, metadata={"vehicle_id": str(vehicle.id)})
db.commit()
db.refresh(vehicle)
return vehicle_payload(vehicle)
@app.get("/api/v1/admin/vehicles/{vehicle_id}")
def get_vehicle(vehicle_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
vehicle = db.get(Vehicle, vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
return vehicle_payload(vehicle)
@app.patch("/api/v1/admin/vehicles/{vehicle_id}")
def update_vehicle(vehicle_id: uuid.UUID, request: VehicleUpdate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
vehicle = db.get(Vehicle, vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
changes = request.model_dump(exclude_unset=True)
for field, value in changes.items():
setattr(vehicle, field, value)
audit(db, "admin.vehicle_updated", actor_user_id=actor.id, target_user_id=vehicle.user_id, metadata={"vehicle_id": str(vehicle.id), "fields": list(changes)})
db.commit()
db.refresh(vehicle)
return vehicle_payload(vehicle)
@app.delete("/api/v1/admin/vehicles/{vehicle_id}", status_code=204)
def delete_vehicle(vehicle_id: uuid.UUID, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
vehicle = db.get(Vehicle, vehicle_id)
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
audit(db, "admin.vehicle_deleted", actor_user_id=actor.id, target_user_id=vehicle.user_id, metadata={"vehicle_id": str(vehicle.id)})
db.delete(vehicle)
db.commit()
return None
@app.get("/api/v1/admin/rides")
def list_rides(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user_id: uuid.UUID | None = None, vehicle_id: uuid.UUID | None = None, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
query = select(Ride)
count_query = select(func.count()).select_from(Ride)
if user_id:
query = query.where(Ride.user_id == user_id)
count_query = count_query.where(Ride.user_id == user_id)
if vehicle_id:
query = query.where(Ride.vehicle_id == vehicle_id)
count_query = count_query.where(Ride.vehicle_id == vehicle_id)
total = db.scalar(count_query) or 0
rides = db.scalars(query.order_by(Ride.started_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
return {"items": [ride_payload(item) for item in rides], "page": page, "page_size": page_size, "total": total}
@app.post("/api/v1/admin/rides", status_code=201)
def create_ride(request: RideCreate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
if db.get(User, request.user_id) is None:
raise HTTPException(status_code=404, detail="User not found")
if request.vehicle_id is not None:
vehicle = db.get(Vehicle, request.vehicle_id)
if vehicle is None or vehicle.user_id != request.user_id:
raise HTTPException(status_code=400, detail="Vehicle does not belong to user")
ride = Ride(**request.model_dump())
db.add(ride)
db.flush()
audit(db, "admin.ride_created", actor_user_id=actor.id, target_user_id=ride.user_id, metadata={"ride_id": str(ride.id)})
db.commit()
db.refresh(ride)
return ride_payload(ride)
@app.get("/api/v1/admin/rides/{ride_id}")
def get_ride(ride_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
ride = db.get(Ride, ride_id)
if ride is None:
raise HTTPException(status_code=404, detail="Ride not found")
return ride_payload(ride)
@app.patch("/api/v1/admin/rides/{ride_id}")
def update_ride(ride_id: uuid.UUID, request: RideUpdate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
ride = db.get(Ride, ride_id)
if ride is None:
raise HTTPException(status_code=404, detail="Ride not found")
changes = request.model_dump(exclude_unset=True)
if "vehicle_id" in changes and changes["vehicle_id"] is not None:
vehicle = db.get(Vehicle, changes["vehicle_id"])
if vehicle is None or vehicle.user_id != ride.user_id:
raise HTTPException(status_code=400, detail="Vehicle does not belong to user")
for field, value in changes.items():
setattr(ride, field, value)
audit(db, "admin.ride_updated", actor_user_id=actor.id, target_user_id=ride.user_id, metadata={"ride_id": str(ride.id), "fields": list(changes)})
db.commit()
db.refresh(ride)
return ride_payload(ride)
@app.delete("/api/v1/admin/rides/{ride_id}", status_code=204)
def archive_ride(ride_id: uuid.UUID, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
ride = db.get(Ride, ride_id)
if ride is None:
raise HTTPException(status_code=404, detail="Ride not found")
ride.status = "archived"
audit(db, "admin.ride_archived", actor_user_id=actor.id, target_user_id=ride.user_id, metadata={"ride_id": str(ride.id)})
db.commit()
return None
@app.get("/api/v1/vehicles")
def list_my_vehicles(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
vehicles = db.scalars(select(Vehicle).where(Vehicle.user_id == user.id).order_by(Vehicle.created_at.desc())).all()
return [vehicle_payload(vehicle) for vehicle in vehicles]
@app.post("/api/v1/vehicles", status_code=201)
def create_my_vehicle(request: UserVehicleCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
vehicle = Vehicle(user_id=user.id, **request.model_dump())
db.add(vehicle)
db.flush()
audit(db, "vehicle.created", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)})
db.commit()
db.refresh(vehicle)
return vehicle_payload(vehicle)
@app.get("/api/v1/vehicles/{vehicle_id}")
def get_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id))
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
return vehicle_payload(vehicle)
@app.patch("/api/v1/vehicles/{vehicle_id}")
def update_my_vehicle(vehicle_id: uuid.UUID, request: VehicleUpdate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id))
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
changes = request.model_dump(exclude_unset=True)
for field, value in changes.items():
setattr(vehicle, field, value)
audit(db, "vehicle.updated", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id), "fields": list(changes)})
db.commit()
db.refresh(vehicle)
return vehicle_payload(vehicle)
@app.delete("/api/v1/vehicles/{vehicle_id}", status_code=204)
def delete_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id))
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
audit(db, "vehicle.deleted", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)})
db.delete(vehicle)
db.commit()
return None
@app.get("/api/v1/rides")
def list_my_rides(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
query = select(Ride).where(Ride.user_id == user.id)
total = db.scalar(select(func.count()).select_from(Ride).where(Ride.user_id == user.id)) or 0
rides = db.scalars(query.order_by(Ride.started_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
return {"items": [ride_payload(ride) for ride in rides], "page": page, "page_size": page_size, "total": total}
@app.post("/api/v1/rides", status_code=201)
def create_my_ride(request: UserRideCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
if request.vehicle_id is not None:
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == request.vehicle_id, Vehicle.user_id == user.id))
if vehicle is None:
raise HTTPException(status_code=400, detail="Vehicle not found")
ride = Ride(user_id=user.id, **request.model_dump())
db.add(ride)
db.flush()
audit(db, "ride.created", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride.id)})
db.commit()
db.refresh(ride)
return ride_payload(ride)
@app.get("/api/v1/rides/{ride_id}")
def get_my_ride(ride_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user.id))
if ride is None:
raise HTTPException(status_code=404, detail="Ride not found")
return ride_payload(ride)
def telemetry_payload(point: TelemetryPoint) -> dict:
return {"id": str(point.id), "ride_id": str(point.ride_id), "recorded_at": point.recorded_at, "latitude": point.latitude, "longitude": point.longitude, "speed_mph": point.speed_mph, "lean_angle_deg": point.lean_angle_deg, "g_force": point.g_force, "accel_g": point.accel_g, "brake_g": point.brake_g}
def get_owned_ride(ride_id: uuid.UUID, user_id: uuid.UUID, db: DbSession) -> Ride:
ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user_id))
if ride is None:
raise HTTPException(status_code=404, detail="Ride not found")
return ride
@app.post("/api/v1/rides/{ride_id}/telemetry", status_code=201)
def ingest_telemetry(ride_id: uuid.UUID, request: TelemetryBatch, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
get_owned_ride(ride_id, user.id, db)
points = [TelemetryPoint(ride_id=ride_id, **point.model_dump()) for point in request.points]
db.add_all(points)
audit(db, "telemetry.ingested", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride_id), "point_count": len(points)})
db.commit()
return {"accepted": len(points)}
@app.get("/api/v1/rides/{ride_id}/telemetry")
def list_my_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
get_owned_ride(ride_id, user.id, db)
points = db.scalars(select(TelemetryPoint).where(TelemetryPoint.ride_id == ride_id).order_by(TelemetryPoint.recorded_at.asc()).limit(limit)).all()
return {"items": [telemetry_payload(point) for point in points], "count": len(points)}
@app.get("/api/v1/admin/rides/{ride_id}/telemetry")
def list_admin_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
if db.get(Ride, ride_id) is None:
raise HTTPException(status_code=404, detail="Ride not found")
points = db.scalars(select(TelemetryPoint).where(TelemetryPoint.ride_id == ride_id).order_by(TelemetryPoint.recorded_at.asc()).limit(limit)).all()
return {"items": [telemetry_payload(point) for point in points], "count": len(points)}
def subscription_payload(subscription: Subscription) -> dict:
return {"id": str(subscription.id), "user_id": str(subscription.user_id), "plan": subscription.plan, "status": subscription.status, "current_period_start": subscription.current_period_start, "current_period_end": subscription.current_period_end, "created_at": subscription.created_at, "updated_at": subscription.updated_at}
def entitlement_payload(entitlement: Entitlement) -> dict:
return {"id": str(entitlement.id), "user_id": str(entitlement.user_id), "key": entitlement.key, "value": entitlement.value, "expires_at": entitlement.expires_at, "created_at": entitlement.created_at}
def get_or_create_subscription(user_id: uuid.UUID, db: DbSession) -> Subscription:
subscription = db.scalar(select(Subscription).where(Subscription.user_id == user_id))
if subscription is None:
subscription = Subscription(user_id=user_id, plan="free", status="active")
db.add(subscription)
db.flush()
return subscription
@app.get("/api/v1/me/subscription")
def get_my_subscription(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
subscription = get_or_create_subscription(user.id, db)
db.commit()
db.refresh(subscription)
return subscription_payload(subscription)
@app.get("/api/v1/me/entitlements")
def list_my_entitlements(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
entitlements = db.scalars(select(Entitlement).where(Entitlement.user_id == user.id).order_by(Entitlement.created_at.desc())).all()
return [entitlement_payload(item) for item in entitlements if item.expires_at is None or item.expires_at > datetime.now(timezone.utc)]
@app.get("/api/v1/admin/subscriptions")
def list_subscriptions(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
total = db.scalar(select(func.count()).select_from(Subscription)) or 0
subscriptions = db.scalars(select(Subscription).order_by(Subscription.updated_at.desc()).offset((page - 1) * page_size).limit(page_size)).all()
return {"items": [subscription_payload(item) for item in subscriptions], "page": page, "page_size": page_size, "total": total}
@app.patch("/api/v1/admin/users/{user_id}/subscription")
def update_subscription(user_id: uuid.UUID, request: SubscriptionUpdate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
if db.get(User, user_id) is None:
raise HTTPException(status_code=404, detail="User not found")
subscription = get_or_create_subscription(user_id, db)
subscription.plan = request.plan
subscription.status = request.status
subscription.current_period_start = request.current_period_start
subscription.current_period_end = request.current_period_end
audit(db, "admin.subscription_updated", actor_user_id=actor.id, target_user_id=user_id, metadata={"plan": request.plan, "status": request.status})
db.commit()
db.refresh(subscription)
return subscription_payload(subscription)
@app.get("/api/v1/admin/users/{user_id}/entitlements")
def list_entitlements(user_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
if db.get(User, user_id) is None:
raise HTTPException(status_code=404, detail="User not found")
return [entitlement_payload(item) for item in db.scalars(select(Entitlement).where(Entitlement.user_id == user_id).order_by(Entitlement.created_at.desc())).all()]
@app.post("/api/v1/admin/users/{user_id}/entitlements", status_code=201)
def create_entitlement(user_id: uuid.UUID, request: EntitlementCreate, actor: User = Depends(require_roles("admin")), db: DbSession = Depends(get_db)):
if db.get(User, user_id) is None:
raise HTTPException(status_code=404, detail="User not found")
entitlement = Entitlement(user_id=user_id, **request.model_dump())
db.add(entitlement)
db.flush()
audit(db, "admin.entitlement_created", actor_user_id=actor.id, target_user_id=user_id, metadata={"key": entitlement.key})
db.commit()
db.refresh(entitlement)
return entitlement_payload(entitlement)