Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ GEMINI_API_KEY=your-gemini-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key

# Security
SECRET_KEY=generate-a-secure-random-string
SECRET_KEY= # REQUIRED - Generate with: openssl rand -hex 32

# Database (for production, use PostgreSQL)
# DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/pooltelemetry
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ venv/
dist/
build/
*.egg-info/
desktop/.webpack/
*.js.map

# IDE
.idea/
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 Bbeierle12

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
4 changes: 2 additions & 2 deletions backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Database (Railway provides DATABASE_URL automatically)
DATABASE_URL=postgresql://user:password@host:port/dbname

# Secret key for JWT tokens (generate with: openssl rand -hex 32)
SECRET_KEY=your-secret-key-here
# Secret key for JWT tokens
SECRET_KEY= # REQUIRED - Generate with: openssl rand -hex 32

# CORS origins (comma-separated)
ALLOWED_ORIGINS=https://frontend-mu-wheat.vercel.app,http://localhost:5173
Expand Down
10 changes: 7 additions & 3 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Authentication routes - PIN-based family profiles."""
import uuid
import hashlib
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import List, Tuple

import bcrypt
Expand Down Expand Up @@ -66,14 +66,18 @@ def verify_pin(pin: str, pin_hash: str) -> Tuple[bool, bool]:

def create_access_token(profile_id: str) -> str:
"""Create JWT access token."""
expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
expire = datetime.now(timezone.utc) + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS)
to_encode = {"sub": profile_id, "exp": expire}
return jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)


@router.get("/profiles", response_model=List[ProfileResponse])
async def list_profiles(db: AsyncSession = Depends(get_db)):
"""List all family profiles (for profile selection screen)."""
"""List profiles for selection screen.

Intentionally unauthenticated: needed before login.
Returns names and avatars only, no sensitive data.
"""
result = await db.execute(select(Profile).order_by(Profile.name))
profiles = result.scalars().all()
return profiles
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/routes/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import csv
import json
import re
from datetime import datetime
from datetime import datetime, timezone
from io import StringIO
from pathlib import Path
from typing import List
Expand Down Expand Up @@ -48,7 +48,7 @@ async def export_session(
export_dir = settings.data_directory / "exports"
export_dir.mkdir(exist_ok=True)

timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
format_type = export_request.format

if format_type == "full_json":
Expand Down
12 changes: 6 additions & 6 deletions backend/app/api/routes/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from __future__ import annotations

import uuid
from datetime import datetime
from datetime import datetime, timezone
from typing import List, Optional

from fastapi import APIRouter, Depends, HTTPException, Query, status
Expand All @@ -27,7 +27,7 @@ async def create_session(
):
"""Create a new recording session."""
session_id = uuid.uuid4().hex
now = datetime.utcnow()
now = datetime.now(timezone.utc)

session = Session(
id=session_id,
Expand Down Expand Up @@ -141,9 +141,9 @@ async def update_session(

# Handle status transitions
if updates.status == "recording" and not session.started_at:
session.started_at = datetime.utcnow()
session.started_at = datetime.now(timezone.utc)
elif updates.status == "completed" and not session.ended_at:
session.ended_at = datetime.utcnow()
session.ended_at = datetime.now(timezone.utc)

await db.commit()
await db.refresh(session)
Expand Down Expand Up @@ -173,7 +173,7 @@ async def start_session(
)

session.status = "recording"
session.started_at = datetime.utcnow()
session.started_at = datetime.now(timezone.utc)

await db.commit()
await db.refresh(session)
Expand Down Expand Up @@ -203,7 +203,7 @@ async def stop_session(
)

session.status = "completed"
session.ended_at = datetime.utcnow()
session.ended_at = datetime.now(timezone.utc)

await db.commit()
await db.refresh(session)
Expand Down
18 changes: 9 additions & 9 deletions backend/app/api/websockets/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import asyncio
import json
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, Optional, Set

from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
Expand Down Expand Up @@ -114,7 +114,7 @@ async def events_websocket(
await websocket.send_json({
"type": "connected",
"session_id": session_id,
"timestamp": datetime.utcnow().isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
})

try:
Expand Down Expand Up @@ -151,7 +151,7 @@ async def broadcast_ball_update(session_id: str, balls: list):
"""Broadcast ball position updates."""
await manager.broadcast(session_id, {
"type": "ball_update",
"timestamp_ms": int(datetime.utcnow().timestamp() * 1000),
"timestamp_ms": int(datetime.now(timezone.utc).timestamp() * 1000),
"balls": balls,
})

Expand All @@ -160,7 +160,7 @@ async def broadcast_event(session_id: str, event_type: str, data: dict):
"""Broadcast a game event."""
await manager.broadcast(session_id, {
"type": event_type,
"timestamp_ms": int(datetime.utcnow().timestamp() * 1000),
"timestamp_ms": int(datetime.now(timezone.utc).timestamp() * 1000),
"data": data,
})

Expand All @@ -169,7 +169,7 @@ async def broadcast_shot(session_id: str, shot_data: dict):
"""Broadcast shot detected."""
await manager.broadcast(session_id, {
"type": "shot",
"timestamp_ms": int(datetime.utcnow().timestamp() * 1000),
"timestamp_ms": int(datetime.now(timezone.utc).timestamp() * 1000),
"shot": shot_data,
})

Expand All @@ -178,7 +178,7 @@ async def broadcast_pocket(session_id: str, ball_name: str, pocket_id: str):
"""Broadcast ball pocketed."""
await manager.broadcast(session_id, {
"type": "pocket",
"timestamp_ms": int(datetime.utcnow().timestamp() * 1000),
"timestamp_ms": int(datetime.now(timezone.utc).timestamp() * 1000),
"ball": ball_name,
"pocket": pocket_id,
})
Expand All @@ -188,7 +188,7 @@ async def broadcast_foul(session_id: str, foul_type: str, details: dict):
"""Broadcast foul detected."""
await manager.broadcast(session_id, {
"type": "foul",
"timestamp_ms": int(datetime.utcnow().timestamp() * 1000),
"timestamp_ms": int(datetime.now(timezone.utc).timestamp() * 1000),
"foul_type": foul_type,
"details": details,
})
Expand All @@ -198,7 +198,7 @@ async def broadcast_status(session_id: str, status: str, message: str = None):
"""Broadcast session status change."""
await manager.broadcast(session_id, {
"type": "status",
"timestamp_ms": int(datetime.utcnow().timestamp() * 1000),
"timestamp_ms": int(datetime.now(timezone.utc).timestamp() * 1000),
"status": status,
"message": message,
})
Expand All @@ -213,7 +213,7 @@ async def store_and_broadcast_event(
):
"""Store an event in the database and broadcast to clients."""
if timestamp_ms is None:
timestamp_ms = int(datetime.utcnow().timestamp() * 1000)
timestamp_ms = int(datetime.now(timezone.utc).timestamp() * 1000)

# Store in database
async with async_session() as db:
Expand Down
10 changes: 5 additions & 5 deletions backend/app/api/websockets/video.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime
from datetime import datetime, timezone
from typing import Dict, List, Optional, Tuple

import cv2
Expand Down Expand Up @@ -347,7 +347,7 @@ async def handle_mobile_camera_session(
# Notify existing consumers that producer is now connected
await vs.broadcast_to_consumers({
"type": "producer_connected",
"timestamp": datetime.utcnow().isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
})

# Update session status
Expand Down Expand Up @@ -407,7 +407,7 @@ async def handle_mobile_camera_session(
print(f"[MOBILE] Producer disconnected, notifying {len(vs.consumers)} consumers")
await vs.broadcast_to_consumers({
"type": "producer_disconnected",
"timestamp": datetime.utcnow().isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
})
await update_session_status(session_id, "completed")
else:
Expand Down Expand Up @@ -496,7 +496,7 @@ async def video_websocket(
"source_type": source_type,
"resolution": resolution,
"framerate": framerate,
"timestamp": datetime.utcnow().isoformat(),
"timestamp": datetime.now(timezone.utc).isoformat(),
})

# Calculate frame interval
Expand All @@ -523,7 +523,7 @@ async def video_websocket(
await websocket.send_json({
"type": "frame",
"data": frame_b64,
"timestamp_ms": int(datetime.utcnow().timestamp() * 1000),
"timestamp_ms": int(datetime.now(timezone.utc).timestamp() * 1000),
})
else:
failed_frames += 1
Expand Down
24 changes: 21 additions & 3 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pathlib import Path
from typing import List, Optional

from pydantic import Field
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings


Expand All @@ -16,14 +16,32 @@ class Settings(BaseSettings):
# Application
app_name: str = "Pool Telemetry"
debug: bool = False
secret_key: str = Field(default="change-me-in-production")
secret_key: str = Field(..., description="Required. Generate with: openssl rand -hex 32")

# Database
# Database - Railway provides DATABASE_URL, we need to convert for async
database_url: str = Field(default="sqlite+aiosqlite:///./data/pool_telemetry.db")

@field_validator("database_url", mode="before")
@classmethod
def convert_postgres_url(cls, v: str) -> str:
"""Convert PostgreSQL URL to async format for SQLAlchemy."""
if v and v.startswith("postgresql://"):
# Railway provides postgresql:// but SQLAlchemy async needs postgresql+asyncpg://
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
return v

# CORS
allowed_origins: List[str] = Field(default=["http://localhost:5173", "http://localhost:3000"])

@field_validator("allowed_origins", mode="before")
@classmethod
def parse_allowed_origins(cls, v):
"""Parse allowed origins from string or list."""
if isinstance(v, str):
# Handle comma-separated string or single URL
return [origin.strip() for origin in v.split(",") if origin.strip()]
return v

# Storage
data_directory: Path = Field(default=Path("./data"))
max_upload_size_mb: int = 2000
Expand Down
13 changes: 4 additions & 9 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@ async def lifespan(app: FastAPI):
CORSMiddleware,
allow_origins=settings.allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)

# REST API routes
Expand Down Expand Up @@ -82,10 +82,5 @@ async def root():

@app.get("/api/health")
async def health():
"""Detailed health check."""
return {
"status": "healthy",
"database": "connected",
"gemini_configured": settings.gemini_api_key is not None,
"anthropic_configured": settings.anthropic_api_key is not None,
}
"""Health check endpoint."""
return {"status": "healthy", "version": "2.0.0"}
11 changes: 0 additions & 11 deletions backend/nixpacks.toml

This file was deleted.

4 changes: 2 additions & 2 deletions backend/railway.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS"
"builder": "DOCKERFILE",
"dockerfilePath": "Dockerfile"
},
"deploy": {
"startCommand": "uvicorn app.main:app --host 0.0.0.0 --port $PORT",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
Expand Down
Loading