init commit
This commit is contained in:
295
services/api_gateway/main.py
Normal file
295
services/api_gateway/main.py
Normal file
@@ -0,0 +1,295 @@
|
||||
from fastapi import FastAPI, HTTPException, Request, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
import httpx
|
||||
import time
|
||||
from typing import Dict
|
||||
from shared.config import settings
|
||||
import asyncio
|
||||
|
||||
app = FastAPI(title="API Gateway", version="1.0.0")
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Service registry
|
||||
SERVICES = {
|
||||
"users": "http://localhost:8001",
|
||||
"emergency": "http://localhost:8002",
|
||||
"location": "http://localhost:8003",
|
||||
"calendar": "http://localhost:8004",
|
||||
"notifications": "http://localhost:8005"
|
||||
}
|
||||
|
||||
# Rate limiting (simple in-memory implementation)
|
||||
request_counts: Dict[str, Dict[str, int]] = {}
|
||||
RATE_LIMIT_REQUESTS = 100 # requests per minute
|
||||
RATE_LIMIT_WINDOW = 60 # seconds
|
||||
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""Get client IP address"""
|
||||
x_forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if x_forwarded_for:
|
||||
return x_forwarded_for.split(",")[0].strip()
|
||||
return request.client.host
|
||||
|
||||
|
||||
def is_rate_limited(client_ip: str) -> bool:
|
||||
"""Check if client is rate limited"""
|
||||
current_time = int(time.time())
|
||||
window_start = current_time - RATE_LIMIT_WINDOW
|
||||
|
||||
if client_ip not in request_counts:
|
||||
request_counts[client_ip] = {}
|
||||
|
||||
# Clean old entries
|
||||
request_counts[client_ip] = {
|
||||
timestamp: count for timestamp, count in request_counts[client_ip].items()
|
||||
if int(timestamp) > window_start
|
||||
}
|
||||
|
||||
# Count requests in current window
|
||||
total_requests = sum(request_counts[client_ip].values())
|
||||
|
||||
if total_requests >= RATE_LIMIT_REQUESTS:
|
||||
return True
|
||||
|
||||
# Add current request
|
||||
timestamp_key = str(current_time)
|
||||
request_counts[client_ip][timestamp_key] = request_counts[client_ip].get(timestamp_key, 0) + 1
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def proxy_request(service_url: str, path: str, method: str, headers: dict, body: bytes = None, params: dict = None):
|
||||
"""Proxy request to microservice"""
|
||||
url = f"{service_url}{path}"
|
||||
|
||||
# Remove hop-by-hop headers
|
||||
filtered_headers = {
|
||||
k: v for k, v in headers.items()
|
||||
if k.lower() not in ["host", "connection", "upgrade", "proxy-connection",
|
||||
"proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding"]
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
headers=filtered_headers,
|
||||
content=body,
|
||||
params=params
|
||||
)
|
||||
return response
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(status_code=504, detail="Service timeout")
|
||||
except httpx.ConnectError:
|
||||
raise HTTPException(status_code=503, detail="Service unavailable")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Proxy error: {str(e)}")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def rate_limiting_middleware(request: Request, call_next):
|
||||
"""Rate limiting middleware"""
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# Skip rate limiting for health checks
|
||||
if request.url.path.endswith("/health"):
|
||||
return await call_next(request)
|
||||
|
||||
if is_rate_limited(client_ip):
|
||||
return JSONResponse(
|
||||
status_code=429,
|
||||
content={"detail": "Rate limit exceeded"}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# User Service routes
|
||||
@app.api_route("/api/v1/register", methods=["POST"])
|
||||
@app.api_route("/api/v1/login", methods=["POST"])
|
||||
@app.api_route("/api/v1/profile", methods=["GET", "PUT"])
|
||||
async def user_service_proxy(request: Request):
|
||||
"""Proxy requests to User Service"""
|
||||
body = await request.body()
|
||||
response = await proxy_request(
|
||||
SERVICES["users"],
|
||||
request.url.path,
|
||||
request.method,
|
||||
dict(request.headers),
|
||||
body,
|
||||
dict(request.query_params)
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=response.status_code,
|
||||
content=response.json(),
|
||||
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
|
||||
)
|
||||
|
||||
|
||||
# Emergency Service routes
|
||||
@app.api_route("/api/v1/alert", methods=["POST"])
|
||||
@app.api_route("/api/v1/alert/{alert_id}/respond", methods=["POST"])
|
||||
@app.api_route("/api/v1/alert/{alert_id}/resolve", methods=["PUT"])
|
||||
@app.api_route("/api/v1/alerts/my", methods=["GET"])
|
||||
@app.api_route("/api/v1/alerts/active", methods=["GET"])
|
||||
async def emergency_service_proxy(request: Request):
|
||||
"""Proxy requests to Emergency Service"""
|
||||
body = await request.body()
|
||||
response = await proxy_request(
|
||||
SERVICES["emergency"],
|
||||
request.url.path,
|
||||
request.method,
|
||||
dict(request.headers),
|
||||
body,
|
||||
dict(request.query_params)
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=response.status_code,
|
||||
content=response.json(),
|
||||
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
|
||||
)
|
||||
|
||||
|
||||
# Location Service routes
|
||||
@app.api_route("/api/v1/update-location", methods=["POST"])
|
||||
@app.api_route("/api/v1/user-location/{user_id}", methods=["GET"])
|
||||
@app.api_route("/api/v1/nearby-users", methods=["GET"])
|
||||
@app.api_route("/api/v1/location-history", methods=["GET"])
|
||||
@app.api_route("/api/v1/location", methods=["DELETE"])
|
||||
async def location_service_proxy(request: Request):
|
||||
"""Proxy requests to Location Service"""
|
||||
body = await request.body()
|
||||
response = await proxy_request(
|
||||
SERVICES["location"],
|
||||
request.url.path,
|
||||
request.method,
|
||||
dict(request.headers),
|
||||
body,
|
||||
dict(request.query_params)
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=response.status_code,
|
||||
content=response.json(),
|
||||
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
|
||||
)
|
||||
|
||||
|
||||
# Calendar Service routes
|
||||
@app.api_route("/api/v1/entries", methods=["GET", "POST"])
|
||||
@app.api_route("/api/v1/entries/{entry_id}", methods=["DELETE"])
|
||||
@app.api_route("/api/v1/cycle-overview", methods=["GET"])
|
||||
@app.api_route("/api/v1/insights", methods=["GET"])
|
||||
async def calendar_service_proxy(request: Request):
|
||||
"""Proxy requests to Calendar Service"""
|
||||
body = await request.body()
|
||||
response = await proxy_request(
|
||||
SERVICES["calendar"],
|
||||
request.url.path,
|
||||
request.method,
|
||||
dict(request.headers),
|
||||
body,
|
||||
dict(request.query_params)
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=response.status_code,
|
||||
content=response.json(),
|
||||
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
|
||||
)
|
||||
|
||||
|
||||
# Notification Service routes
|
||||
@app.api_route("/api/v1/register-device", methods=["POST"])
|
||||
@app.api_route("/api/v1/send-notification", methods=["POST"])
|
||||
@app.api_route("/api/v1/device-token", methods=["DELETE"])
|
||||
@app.api_route("/api/v1/my-devices", methods=["GET"])
|
||||
async def notification_service_proxy(request: Request):
|
||||
"""Proxy requests to Notification Service"""
|
||||
body = await request.body()
|
||||
response = await proxy_request(
|
||||
SERVICES["notifications"],
|
||||
request.url.path,
|
||||
request.method,
|
||||
dict(request.headers),
|
||||
body,
|
||||
dict(request.query_params)
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=response.status_code,
|
||||
content=response.json(),
|
||||
headers={k: v for k, v in response.headers.items() if k.lower() not in ["content-length", "transfer-encoding"]}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def gateway_health_check():
|
||||
"""Gateway health check"""
|
||||
return {"status": "healthy", "service": "api-gateway"}
|
||||
|
||||
|
||||
@app.get("/api/v1/services-status")
|
||||
async def check_services_status():
|
||||
"""Check status of all microservices"""
|
||||
service_status = {}
|
||||
|
||||
async def check_service(name: str, url: str):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{url}/api/v1/health")
|
||||
service_status[name] = {
|
||||
"status": "healthy" if response.status_code == 200 else "unhealthy",
|
||||
"response_time_ms": response.elapsed.total_seconds() * 1000,
|
||||
"url": url
|
||||
}
|
||||
except Exception as e:
|
||||
service_status[name] = {
|
||||
"status": "unhealthy",
|
||||
"error": str(e),
|
||||
"url": url
|
||||
}
|
||||
|
||||
# Check all services concurrently
|
||||
tasks = [check_service(name, url) for name, url in SERVICES.items()]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
all_healthy = all(status["status"] == "healthy" for status in service_status.values())
|
||||
|
||||
return {
|
||||
"gateway_status": "healthy",
|
||||
"all_services_healthy": all_healthy,
|
||||
"services": service_status
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
"""Root endpoint with API information"""
|
||||
return {
|
||||
"service": "Women Safety App API Gateway",
|
||||
"version": "1.0.0",
|
||||
"status": "running",
|
||||
"endpoints": {
|
||||
"auth": "/api/v1/register, /api/v1/login",
|
||||
"profile": "/api/v1/profile",
|
||||
"emergency": "/api/v1/alert, /api/v1/alerts/*",
|
||||
"location": "/api/v1/update-location, /api/v1/nearby-users",
|
||||
"calendar": "/api/v1/entries, /api/v1/cycle-overview",
|
||||
"notifications": "/api/v1/register-device, /api/v1/send-notification"
|
||||
},
|
||||
"docs": "/docs"
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
413
services/calendar_service/main.py
Normal file
413
services/calendar_service/main.py
Normal file
@@ -0,0 +1,413 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, desc
|
||||
from shared.config import settings
|
||||
from shared.database import get_db
|
||||
from services.calendar_service.models import CalendarEntry, CycleData, HealthInsights
|
||||
from services.user_service.main import get_current_user
|
||||
from services.user_service.models import User
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date, timedelta
|
||||
from enum import Enum
|
||||
|
||||
app = FastAPI(title="Calendar Service", version="1.0.0")
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class EntryType(str, Enum):
|
||||
PERIOD = "period"
|
||||
OVULATION = "ovulation"
|
||||
SYMPTOMS = "symptoms"
|
||||
MEDICATION = "medication"
|
||||
MOOD = "mood"
|
||||
EXERCISE = "exercise"
|
||||
APPOINTMENT = "appointment"
|
||||
|
||||
|
||||
class FlowIntensity(str, Enum):
|
||||
LIGHT = "light"
|
||||
MEDIUM = "medium"
|
||||
HEAVY = "heavy"
|
||||
SPOTTING = "spotting"
|
||||
|
||||
|
||||
class MoodType(str, Enum):
|
||||
HAPPY = "happy"
|
||||
SAD = "sad"
|
||||
ANXIOUS = "anxious"
|
||||
IRRITATED = "irritated"
|
||||
ENERGETIC = "energetic"
|
||||
TIRED = "tired"
|
||||
|
||||
|
||||
class CalendarEntryCreate(BaseModel):
|
||||
entry_date: date
|
||||
entry_type: EntryType
|
||||
flow_intensity: Optional[FlowIntensity] = None
|
||||
period_symptoms: Optional[str] = Field(None, max_length=500)
|
||||
mood: Optional[MoodType] = None
|
||||
energy_level: Optional[int] = Field(None, ge=1, le=5)
|
||||
sleep_hours: Optional[int] = Field(None, ge=0, le=24)
|
||||
symptoms: Optional[str] = Field(None, max_length=1000)
|
||||
medications: Optional[str] = Field(None, max_length=500)
|
||||
notes: Optional[str] = Field(None, max_length=1000)
|
||||
|
||||
|
||||
class CalendarEntryResponse(BaseModel):
|
||||
id: int
|
||||
uuid: str
|
||||
entry_date: date
|
||||
entry_type: str
|
||||
flow_intensity: Optional[str]
|
||||
period_symptoms: Optional[str]
|
||||
mood: Optional[str]
|
||||
energy_level: Optional[int]
|
||||
sleep_hours: Optional[int]
|
||||
symptoms: Optional[str]
|
||||
medications: Optional[str]
|
||||
notes: Optional[str]
|
||||
is_predicted: bool
|
||||
confidence_score: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CycleDataResponse(BaseModel):
|
||||
id: int
|
||||
cycle_start_date: date
|
||||
cycle_length: Optional[int]
|
||||
period_length: Optional[int]
|
||||
ovulation_date: Optional[date]
|
||||
fertile_window_start: Optional[date]
|
||||
fertile_window_end: Optional[date]
|
||||
next_period_predicted: Optional[date]
|
||||
avg_cycle_length: Optional[int]
|
||||
avg_period_length: Optional[int]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class HealthInsightResponse(BaseModel):
|
||||
id: int
|
||||
insight_type: str
|
||||
title: str
|
||||
description: str
|
||||
recommendation: Optional[str]
|
||||
confidence_level: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CycleOverview(BaseModel):
|
||||
current_cycle_day: Optional[int]
|
||||
current_phase: str # menstrual, follicular, ovulation, luteal
|
||||
next_period_date: Optional[date]
|
||||
days_until_period: Optional[int]
|
||||
cycle_regularity: str # very_regular, regular, irregular, very_irregular
|
||||
avg_cycle_length: Optional[int]
|
||||
|
||||
|
||||
def calculate_cycle_phase(cycle_start: date, cycle_length: int, current_date: date) -> str:
|
||||
"""Calculate current cycle phase"""
|
||||
days_since_start = (current_date - cycle_start).days
|
||||
|
||||
if days_since_start <= 5:
|
||||
return "menstrual"
|
||||
elif days_since_start <= cycle_length // 2 - 2:
|
||||
return "follicular"
|
||||
elif cycle_length // 2 - 2 < days_since_start <= cycle_length // 2 + 2:
|
||||
return "ovulation"
|
||||
else:
|
||||
return "luteal"
|
||||
|
||||
|
||||
async def calculate_predictions(user_id: int, db: AsyncSession):
|
||||
"""Calculate cycle predictions based on historical data"""
|
||||
# Get last 6 cycles for calculations
|
||||
cycles = await db.execute(
|
||||
select(CycleData)
|
||||
.filter(CycleData.user_id == user_id)
|
||||
.order_by(desc(CycleData.cycle_start_date))
|
||||
.limit(6)
|
||||
)
|
||||
cycle_list = cycles.scalars().all()
|
||||
|
||||
if len(cycle_list) < 2:
|
||||
return None
|
||||
|
||||
# Calculate averages
|
||||
cycle_lengths = [c.cycle_length for c in cycle_list if c.cycle_length]
|
||||
period_lengths = [c.period_length for c in cycle_list if c.period_length]
|
||||
|
||||
if not cycle_lengths:
|
||||
return None
|
||||
|
||||
avg_cycle = sum(cycle_lengths) / len(cycle_lengths)
|
||||
avg_period = sum(period_lengths) / len(period_lengths) if period_lengths else 5
|
||||
|
||||
# Predict next period
|
||||
last_cycle = cycle_list[0]
|
||||
next_period_date = last_cycle.cycle_start_date + timedelta(days=int(avg_cycle))
|
||||
|
||||
return {
|
||||
"avg_cycle_length": int(avg_cycle),
|
||||
"avg_period_length": int(avg_period),
|
||||
"next_period_predicted": next_period_date,
|
||||
"ovulation_date": last_cycle.cycle_start_date + timedelta(days=int(avg_cycle // 2))
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/entries", response_model=CalendarEntryResponse)
|
||||
async def create_calendar_entry(
|
||||
entry_data: CalendarEntryCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create new calendar entry"""
|
||||
|
||||
# Check if entry already exists for this date and type
|
||||
existing = await db.execute(
|
||||
select(CalendarEntry).filter(
|
||||
and_(
|
||||
CalendarEntry.user_id == current_user.id,
|
||||
CalendarEntry.entry_date == entry_data.entry_date,
|
||||
CalendarEntry.entry_type == entry_data.entry_type.value
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Entry already exists for this date and type"
|
||||
)
|
||||
|
||||
db_entry = CalendarEntry(
|
||||
user_id=current_user.id,
|
||||
entry_date=entry_data.entry_date,
|
||||
entry_type=entry_data.entry_type.value,
|
||||
flow_intensity=entry_data.flow_intensity.value if entry_data.flow_intensity else None,
|
||||
period_symptoms=entry_data.period_symptoms,
|
||||
mood=entry_data.mood.value if entry_data.mood else None,
|
||||
energy_level=entry_data.energy_level,
|
||||
sleep_hours=entry_data.sleep_hours,
|
||||
symptoms=entry_data.symptoms,
|
||||
medications=entry_data.medications,
|
||||
notes=entry_data.notes,
|
||||
)
|
||||
|
||||
db.add(db_entry)
|
||||
await db.commit()
|
||||
await db.refresh(db_entry)
|
||||
|
||||
# If this is a period entry, update cycle data
|
||||
if entry_data.entry_type == EntryType.PERIOD:
|
||||
await update_cycle_data(current_user.id, entry_data.entry_date, db)
|
||||
|
||||
return CalendarEntryResponse.model_validate(db_entry)
|
||||
|
||||
|
||||
async def update_cycle_data(user_id: int, period_date: date, db: AsyncSession):
|
||||
"""Update cycle data when period is logged"""
|
||||
|
||||
# Get last cycle
|
||||
last_cycle = await db.execute(
|
||||
select(CycleData)
|
||||
.filter(CycleData.user_id == user_id)
|
||||
.order_by(desc(CycleData.cycle_start_date))
|
||||
.limit(1)
|
||||
)
|
||||
last_cycle_data = last_cycle.scalars().first()
|
||||
|
||||
if last_cycle_data:
|
||||
# Calculate cycle length
|
||||
cycle_length = (period_date - last_cycle_data.cycle_start_date).days
|
||||
last_cycle_data.cycle_length = cycle_length
|
||||
|
||||
# Create new cycle
|
||||
predictions = await calculate_predictions(user_id, db)
|
||||
|
||||
new_cycle = CycleData(
|
||||
user_id=user_id,
|
||||
cycle_start_date=period_date,
|
||||
avg_cycle_length=predictions["avg_cycle_length"] if predictions else None,
|
||||
next_period_predicted=predictions["next_period_predicted"] if predictions else None,
|
||||
ovulation_date=predictions["ovulation_date"] if predictions else None,
|
||||
)
|
||||
|
||||
db.add(new_cycle)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@app.get("/api/v1/entries", response_model=List[CalendarEntryResponse])
|
||||
async def get_calendar_entries(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
start_date: Optional[date] = Query(None),
|
||||
end_date: Optional[date] = Query(None),
|
||||
entry_type: Optional[EntryType] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=365)
|
||||
):
|
||||
"""Get calendar entries with optional filtering"""
|
||||
|
||||
query = select(CalendarEntry).filter(CalendarEntry.user_id == current_user.id)
|
||||
|
||||
if start_date:
|
||||
query = query.filter(CalendarEntry.entry_date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(CalendarEntry.entry_date <= end_date)
|
||||
if entry_type:
|
||||
query = query.filter(CalendarEntry.entry_type == entry_type.value)
|
||||
|
||||
query = query.order_by(desc(CalendarEntry.entry_date)).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
entries = result.scalars().all()
|
||||
|
||||
return [CalendarEntryResponse.model_validate(entry) for entry in entries]
|
||||
|
||||
|
||||
@app.get("/api/v1/cycle-overview", response_model=CycleOverview)
|
||||
async def get_cycle_overview(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get current cycle overview and predictions"""
|
||||
|
||||
# Get current cycle
|
||||
current_cycle = await db.execute(
|
||||
select(CycleData)
|
||||
.filter(CycleData.user_id == current_user.id)
|
||||
.order_by(desc(CycleData.cycle_start_date))
|
||||
.limit(1)
|
||||
)
|
||||
cycle_data = current_cycle.scalars().first()
|
||||
|
||||
if not cycle_data:
|
||||
return CycleOverview(
|
||||
current_cycle_day=None,
|
||||
current_phase="unknown",
|
||||
next_period_date=None,
|
||||
days_until_period=None,
|
||||
cycle_regularity="unknown",
|
||||
avg_cycle_length=None
|
||||
)
|
||||
|
||||
today = date.today()
|
||||
current_cycle_day = (today - cycle_data.cycle_start_date).days + 1
|
||||
|
||||
# Calculate current phase
|
||||
cycle_length = cycle_data.avg_cycle_length or 28
|
||||
current_phase = calculate_cycle_phase(cycle_data.cycle_start_date, cycle_length, today)
|
||||
|
||||
# Days until next period
|
||||
next_period_date = cycle_data.next_period_predicted
|
||||
days_until_period = None
|
||||
if next_period_date:
|
||||
days_until_period = (next_period_date - today).days
|
||||
|
||||
# Calculate regularity
|
||||
cycles = await db.execute(
|
||||
select(CycleData)
|
||||
.filter(CycleData.user_id == current_user.id)
|
||||
.order_by(desc(CycleData.cycle_start_date))
|
||||
.limit(6)
|
||||
)
|
||||
cycle_list = cycles.scalars().all()
|
||||
|
||||
regularity = "unknown"
|
||||
if len(cycle_list) >= 3:
|
||||
lengths = [c.cycle_length for c in cycle_list if c.cycle_length]
|
||||
if lengths:
|
||||
variance = max(lengths) - min(lengths)
|
||||
if variance <= 2:
|
||||
regularity = "very_regular"
|
||||
elif variance <= 5:
|
||||
regularity = "regular"
|
||||
elif variance <= 10:
|
||||
regularity = "irregular"
|
||||
else:
|
||||
regularity = "very_irregular"
|
||||
|
||||
return CycleOverview(
|
||||
current_cycle_day=current_cycle_day,
|
||||
current_phase=current_phase,
|
||||
next_period_date=next_period_date,
|
||||
days_until_period=days_until_period,
|
||||
cycle_regularity=regularity,
|
||||
avg_cycle_length=cycle_data.avg_cycle_length
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/insights", response_model=List[HealthInsightResponse])
|
||||
async def get_health_insights(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
limit: int = Query(10, ge=1, le=50)
|
||||
):
|
||||
"""Get personalized health insights"""
|
||||
|
||||
result = await db.execute(
|
||||
select(HealthInsights)
|
||||
.filter(
|
||||
HealthInsights.user_id == current_user.id,
|
||||
HealthInsights.is_dismissed == False
|
||||
)
|
||||
.order_by(desc(HealthInsights.created_at))
|
||||
.limit(limit)
|
||||
)
|
||||
insights = result.scalars().all()
|
||||
|
||||
return [HealthInsightResponse.model_validate(insight) for insight in insights]
|
||||
|
||||
|
||||
@app.delete("/api/v1/entries/{entry_id}")
|
||||
async def delete_calendar_entry(
|
||||
entry_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete calendar entry"""
|
||||
|
||||
result = await db.execute(
|
||||
select(CalendarEntry).filter(
|
||||
and_(
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
)
|
||||
entry = result.scalars().first()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
await db.delete(entry)
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Entry deleted successfully"}
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "service": "calendar-service"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8004)
|
||||
77
services/calendar_service/models.py
Normal file
77
services/calendar_service/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from sqlalchemy import Column, String, Integer, Date, Text, Boolean, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from shared.database import BaseModel
|
||||
import uuid
|
||||
|
||||
|
||||
class CalendarEntry(BaseModel):
|
||||
__tablename__ = "calendar_entries"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
entry_date = Column(Date, nullable=False, index=True)
|
||||
entry_type = Column(String(50), nullable=False) # period, ovulation, symptoms, medication, etc.
|
||||
|
||||
# Period tracking
|
||||
flow_intensity = Column(String(20)) # light, medium, heavy
|
||||
period_symptoms = Column(Text) # cramps, headache, mood, etc.
|
||||
|
||||
# General health
|
||||
mood = Column(String(20)) # happy, sad, anxious, irritated, etc.
|
||||
energy_level = Column(Integer) # 1-5 scale
|
||||
sleep_hours = Column(Integer)
|
||||
|
||||
# Symptoms and notes
|
||||
symptoms = Column(Text) # Any symptoms experienced
|
||||
medications = Column(Text) # Medications taken
|
||||
notes = Column(Text) # Personal notes
|
||||
|
||||
# Predictions and calculations
|
||||
is_predicted = Column(Boolean, default=False) # If this is a predicted entry
|
||||
confidence_score = Column(Integer) # Prediction confidence 1-100
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CalendarEntry user_id={self.user_id} date={self.entry_date} type={self.entry_type}>"
|
||||
|
||||
|
||||
class CycleData(BaseModel):
|
||||
__tablename__ = "cycle_data"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
cycle_start_date = Column(Date, nullable=False)
|
||||
cycle_length = Column(Integer) # Length of this cycle
|
||||
period_length = Column(Integer) # Length of period in this cycle
|
||||
|
||||
# Calculated fields
|
||||
ovulation_date = Column(Date)
|
||||
fertile_window_start = Column(Date)
|
||||
fertile_window_end = Column(Date)
|
||||
next_period_predicted = Column(Date)
|
||||
|
||||
# Cycle characteristics
|
||||
cycle_regularity_score = Column(Integer) # 1-100, how regular is this cycle
|
||||
avg_cycle_length = Column(Integer) # Rolling average
|
||||
avg_period_length = Column(Integer) # Rolling average
|
||||
|
||||
def __repr__(self):
|
||||
return f"<CycleData user_id={self.user_id} start={self.cycle_start_date}>"
|
||||
|
||||
|
||||
class HealthInsights(BaseModel):
|
||||
__tablename__ = "health_insights"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
insight_type = Column(String(50), nullable=False) # cycle_pattern, symptom_pattern, etc.
|
||||
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
recommendation = Column(Text)
|
||||
|
||||
# Metadata
|
||||
confidence_level = Column(String(20)) # high, medium, low
|
||||
data_points_used = Column(Integer) # How many data points were used
|
||||
is_dismissed = Column(Boolean, default=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<HealthInsights user_id={self.user_id} type={self.insight_type}>"
|
||||
319
services/emergency_service/main.py
Normal file
319
services/emergency_service/main.py
Normal file
@@ -0,0 +1,319 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends, status, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from shared.config import settings
|
||||
from shared.database import get_db, AsyncSessionLocal
|
||||
from shared.auth import get_current_user_from_token
|
||||
from services.emergency_service.models import EmergencyAlert, EmergencyResponse
|
||||
from services.emergency_service.schemas import (
|
||||
EmergencyAlertCreate, EmergencyAlertResponse,
|
||||
EmergencyResponseCreate, EmergencyResponseResponse,
|
||||
EmergencyStats
|
||||
)
|
||||
from services.user_service.models import User
|
||||
import httpx
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
app = FastAPI(title="Emergency Service", version="1.0.0")
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
user_data: dict = Depends(get_current_user_from_token),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get current user from token via auth dependency."""
|
||||
# Get full user object from database
|
||||
result = await db.execute(select(User).filter(User.id == user_data["user_id"]))
|
||||
user = result.scalars().first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "service": "emergency_service"}
|
||||
|
||||
|
||||
async def get_nearby_users(latitude: float, longitude: float, radius_km: float = 1.0) -> list:
|
||||
"""Get users within radius using Location Service"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"http://localhost:8003/api/v1/nearby-users",
|
||||
params={
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"radius_km": radius_km
|
||||
},
|
||||
timeout=5.0
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
async def send_emergency_notifications(alert_id: int, nearby_users: list):
|
||||
"""Send push notifications to nearby users"""
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
await client.post(
|
||||
"http://localhost:8005/api/v1/send-emergency-notifications",
|
||||
json={
|
||||
"alert_id": alert_id,
|
||||
"user_ids": [user["user_id"] for user in nearby_users]
|
||||
},
|
||||
timeout=10.0
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Failed to send notifications: {e}")
|
||||
|
||||
|
||||
@app.post("/api/v1/alert", response_model=EmergencyAlertResponse)
|
||||
async def create_emergency_alert(
|
||||
alert_data: EmergencyAlertCreate,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Create new emergency alert and notify nearby users"""
|
||||
|
||||
# Create alert
|
||||
db_alert = EmergencyAlert(
|
||||
user_id=current_user.id,
|
||||
latitude=alert_data.latitude,
|
||||
longitude=alert_data.longitude,
|
||||
address=alert_data.address,
|
||||
alert_type=alert_data.alert_type.value,
|
||||
message=alert_data.message,
|
||||
)
|
||||
|
||||
db.add(db_alert)
|
||||
await db.commit()
|
||||
await db.refresh(db_alert)
|
||||
|
||||
# Get nearby users and send notifications in background
|
||||
background_tasks.add_task(
|
||||
process_emergency_alert,
|
||||
db_alert.id,
|
||||
alert_data.latitude,
|
||||
alert_data.longitude
|
||||
)
|
||||
|
||||
return EmergencyAlertResponse.model_validate(db_alert)
|
||||
|
||||
|
||||
async def process_emergency_alert(alert_id: int, latitude: float, longitude: float):
|
||||
"""Process emergency alert - get nearby users and send notifications"""
|
||||
# Get nearby users
|
||||
nearby_users = await get_nearby_users(latitude, longitude, settings.MAX_EMERGENCY_RADIUS_KM)
|
||||
|
||||
# Update alert with notified users count
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(EmergencyAlert).filter(EmergencyAlert.id == alert_id))
|
||||
alert = result.scalars().first()
|
||||
if alert:
|
||||
alert.notified_users_count = len(nearby_users)
|
||||
await db.commit()
|
||||
|
||||
# Send notifications
|
||||
if nearby_users:
|
||||
await send_emergency_notifications(alert_id, nearby_users)
|
||||
|
||||
|
||||
@app.post("/api/v1/alert/{alert_id}/respond", response_model=EmergencyResponseResponse)
|
||||
async def respond_to_alert(
|
||||
alert_id: int,
|
||||
response_data: EmergencyResponseCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Respond to emergency alert"""
|
||||
|
||||
# Check if alert exists
|
||||
result = await db.execute(select(EmergencyAlert).filter(EmergencyAlert.id == alert_id))
|
||||
alert = result.scalars().first()
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
if alert.is_resolved:
|
||||
raise HTTPException(status_code=400, detail="Alert already resolved")
|
||||
|
||||
# Check if user already responded
|
||||
existing_response = await db.execute(
|
||||
select(EmergencyResponse).filter(
|
||||
EmergencyResponse.alert_id == alert_id,
|
||||
EmergencyResponse.responder_id == current_user.id
|
||||
)
|
||||
)
|
||||
if existing_response.scalars().first():
|
||||
raise HTTPException(status_code=400, detail="You already responded to this alert")
|
||||
|
||||
# Create response
|
||||
db_response = EmergencyResponse(
|
||||
alert_id=alert_id,
|
||||
responder_id=current_user.id,
|
||||
response_type=response_data.response_type.value,
|
||||
message=response_data.message,
|
||||
eta_minutes=response_data.eta_minutes,
|
||||
)
|
||||
|
||||
db.add(db_response)
|
||||
|
||||
# Update responded users count
|
||||
alert.responded_users_count += 1
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(db_response)
|
||||
|
||||
return EmergencyResponseResponse.model_validate(db_response)
|
||||
|
||||
|
||||
@app.put("/api/v1/alert/{alert_id}/resolve")
|
||||
async def resolve_alert(
|
||||
alert_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Mark alert as resolved (only by alert creator)"""
|
||||
|
||||
result = await db.execute(select(EmergencyAlert).filter(EmergencyAlert.id == alert_id))
|
||||
alert = result.scalars().first()
|
||||
|
||||
if not alert:
|
||||
raise HTTPException(status_code=404, detail="Alert not found")
|
||||
|
||||
if alert.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Only alert creator can resolve it")
|
||||
|
||||
if alert.is_resolved:
|
||||
raise HTTPException(status_code=400, detail="Alert already resolved")
|
||||
|
||||
alert.is_resolved = True
|
||||
alert.resolved_at = datetime.utcnow()
|
||||
alert.resolved_by = current_user.id
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"message": "Alert resolved successfully"}
|
||||
|
||||
|
||||
@app.get("/api/v1/alerts/my", response_model=list[EmergencyAlertResponse])
|
||||
async def get_my_alerts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
limit: int = 50
|
||||
):
|
||||
"""Get current user's emergency alerts"""
|
||||
|
||||
result = await db.execute(
|
||||
select(EmergencyAlert)
|
||||
.filter(EmergencyAlert.user_id == current_user.id)
|
||||
.order_by(EmergencyAlert.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
alerts = result.scalars().all()
|
||||
|
||||
return [EmergencyAlertResponse.model_validate(alert) for alert in alerts]
|
||||
|
||||
|
||||
@app.get("/api/v1/alerts/active", response_model=list[EmergencyAlertResponse])
|
||||
async def get_active_alerts(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
limit: int = 20
|
||||
):
|
||||
"""Get active alerts in user's area (last 2 hours)"""
|
||||
|
||||
# Get user's current location first
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response = await client.get(
|
||||
f"http://localhost:8003/api/v1/user-location/{current_user.id}",
|
||||
timeout=5.0
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise HTTPException(status_code=400, detail="User location not available")
|
||||
location_data = response.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Location service unavailable")
|
||||
|
||||
# Get alerts from last 2 hours
|
||||
two_hours_ago = datetime.utcnow() - timedelta(hours=2)
|
||||
|
||||
result = await db.execute(
|
||||
select(EmergencyAlert)
|
||||
.filter(
|
||||
EmergencyAlert.is_resolved == False,
|
||||
EmergencyAlert.created_at >= two_hours_ago
|
||||
)
|
||||
.order_by(EmergencyAlert.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
alerts = result.scalars().all()
|
||||
|
||||
return [EmergencyAlertResponse.model_validate(alert) for alert in alerts]
|
||||
|
||||
|
||||
@app.get("/api/v1/stats", response_model=EmergencyStats)
|
||||
async def get_emergency_stats(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get emergency service statistics"""
|
||||
|
||||
# Get total alerts
|
||||
total_result = await db.execute(select(func.count(EmergencyAlert.id)))
|
||||
total_alerts = total_result.scalar()
|
||||
|
||||
# Get active alerts
|
||||
active_result = await db.execute(
|
||||
select(func.count(EmergencyAlert.id))
|
||||
.filter(EmergencyAlert.is_resolved == False)
|
||||
)
|
||||
active_alerts = active_result.scalar()
|
||||
|
||||
# Get resolved alerts
|
||||
resolved_alerts = total_alerts - active_alerts
|
||||
|
||||
# Get total responders
|
||||
responders_result = await db.execute(
|
||||
select(func.count(func.distinct(EmergencyResponse.responder_id)))
|
||||
)
|
||||
total_responders = responders_result.scalar()
|
||||
|
||||
return EmergencyStats(
|
||||
total_alerts=total_alerts,
|
||||
active_alerts=active_alerts,
|
||||
resolved_alerts=resolved_alerts,
|
||||
avg_response_time_minutes=None, # TODO: Calculate this
|
||||
total_responders=total_responders
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "service": "emergency-service"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8002)
|
||||
44
services/emergency_service/models.py
Normal file
44
services/emergency_service/models.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from shared.database import BaseModel
|
||||
import uuid
|
||||
|
||||
|
||||
class EmergencyAlert(BaseModel):
|
||||
__tablename__ = "emergency_alerts"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# Location at time of alert
|
||||
latitude = Column(Float, nullable=False)
|
||||
longitude = Column(Float, nullable=False)
|
||||
address = Column(String(500))
|
||||
|
||||
# Alert details
|
||||
alert_type = Column(String(50), default="general") # general, medical, violence, etc.
|
||||
message = Column(Text)
|
||||
is_resolved = Column(Boolean, default=False)
|
||||
resolved_at = Column(DateTime(timezone=True))
|
||||
resolved_by = Column(Integer, ForeignKey("users.id"))
|
||||
|
||||
# Response tracking
|
||||
notified_users_count = Column(Integer, default=0)
|
||||
responded_users_count = Column(Integer, default=0)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmergencyAlert {self.uuid}>"
|
||||
|
||||
|
||||
class EmergencyResponse(BaseModel):
|
||||
__tablename__ = "emergency_responses"
|
||||
|
||||
alert_id = Column(Integer, ForeignKey("emergency_alerts.id"), nullable=False, index=True)
|
||||
responder_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
response_type = Column(String(50)) # help_on_way, contacted_authorities, etc.
|
||||
message = Column(Text)
|
||||
eta_minutes = Column(Integer) # Estimated time of arrival
|
||||
|
||||
def __repr__(self):
|
||||
return f"<EmergencyResponse {self.id}>"
|
||||
80
services/emergency_service/schemas.py
Normal file
80
services/emergency_service/schemas.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AlertType(str, Enum):
|
||||
GENERAL = "general"
|
||||
MEDICAL = "medical"
|
||||
VIOLENCE = "violence"
|
||||
HARASSMENT = "harassment"
|
||||
UNSAFE_AREA = "unsafe_area"
|
||||
|
||||
|
||||
class ResponseType(str, Enum):
|
||||
HELP_ON_WAY = "help_on_way"
|
||||
CONTACTED_AUTHORITIES = "contacted_authorities"
|
||||
SAFE_NOW = "safe_now"
|
||||
FALSE_ALARM = "false_alarm"
|
||||
|
||||
|
||||
class EmergencyAlertCreate(BaseModel):
|
||||
latitude: float = Field(..., ge=-90, le=90)
|
||||
longitude: float = Field(..., ge=-180, le=180)
|
||||
alert_type: AlertType = AlertType.GENERAL
|
||||
message: Optional[str] = Field(None, max_length=500)
|
||||
address: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class EmergencyAlertResponse(BaseModel):
|
||||
id: int
|
||||
uuid: str
|
||||
user_id: int
|
||||
latitude: float
|
||||
longitude: float
|
||||
address: Optional[str]
|
||||
alert_type: str
|
||||
message: Optional[str]
|
||||
is_resolved: bool
|
||||
resolved_at: Optional[datetime]
|
||||
notified_users_count: int
|
||||
responded_users_count: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class EmergencyResponseCreate(BaseModel):
|
||||
response_type: ResponseType
|
||||
message: Optional[str] = Field(None, max_length=500)
|
||||
eta_minutes: Optional[int] = Field(None, ge=0, le=240) # Max 4 hours
|
||||
|
||||
|
||||
class EmergencyResponseResponse(BaseModel):
|
||||
id: int
|
||||
alert_id: int
|
||||
responder_id: int
|
||||
response_type: str
|
||||
message: Optional[str]
|
||||
eta_minutes: Optional[int]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NearbyUsersResponse(BaseModel):
|
||||
user_id: int
|
||||
distance_meters: float
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
|
||||
class EmergencyStats(BaseModel):
|
||||
total_alerts: int
|
||||
active_alerts: int
|
||||
resolved_alerts: int
|
||||
avg_response_time_minutes: Optional[float]
|
||||
total_responders: int
|
||||
312
services/location_service/main.py
Normal file
312
services/location_service/main.py
Normal file
@@ -0,0 +1,312 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, text
|
||||
from shared.config import settings
|
||||
from shared.database import get_db
|
||||
from shared.cache import CacheService
|
||||
from services.location_service.models import UserLocation, LocationHistory
|
||||
from services.user_service.main import get_current_user
|
||||
from services.user_service.models import User
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
import math
|
||||
|
||||
app = FastAPI(title="Location Service", version="1.0.0")
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class LocationUpdate(BaseModel):
|
||||
latitude: float = Field(..., ge=-90, le=90)
|
||||
longitude: float = Field(..., ge=-180, le=180)
|
||||
accuracy: Optional[float] = Field(None, ge=0)
|
||||
altitude: Optional[float] = None
|
||||
speed: Optional[float] = Field(None, ge=0)
|
||||
heading: Optional[float] = Field(None, ge=0, le=360)
|
||||
|
||||
|
||||
class LocationResponse(BaseModel):
|
||||
user_id: int
|
||||
latitude: float
|
||||
longitude: float
|
||||
accuracy: Optional[float]
|
||||
updated_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class NearbyUserResponse(BaseModel):
|
||||
user_id: int
|
||||
latitude: float
|
||||
longitude: float
|
||||
distance_meters: float
|
||||
last_seen: datetime
|
||||
|
||||
|
||||
def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Calculate distance between two points using Haversine formula (in meters)"""
|
||||
R = 6371000 # Earth's radius in meters
|
||||
|
||||
lat1_rad = math.radians(lat1)
|
||||
lat2_rad = math.radians(lat2)
|
||||
delta_lat = math.radians(lat2 - lat1)
|
||||
delta_lon = math.radians(lon2 - lon1)
|
||||
|
||||
a = (math.sin(delta_lat / 2) * math.sin(delta_lat / 2) +
|
||||
math.cos(lat1_rad) * math.cos(lat2_rad) *
|
||||
math.sin(delta_lon / 2) * math.sin(delta_lon / 2))
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
distance = R * c
|
||||
return distance
|
||||
|
||||
|
||||
@app.post("/api/v1/update-location")
|
||||
async def update_user_location(
|
||||
location_data: LocationUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update user's current location"""
|
||||
|
||||
if not current_user.location_sharing_enabled:
|
||||
raise HTTPException(status_code=403, detail="Location sharing is disabled")
|
||||
|
||||
# Update or create current location
|
||||
result = await db.execute(
|
||||
select(UserLocation).filter(UserLocation.user_id == current_user.id)
|
||||
)
|
||||
user_location = result.scalars().first()
|
||||
|
||||
if user_location:
|
||||
user_location.latitude = location_data.latitude
|
||||
user_location.longitude = location_data.longitude
|
||||
user_location.accuracy = location_data.accuracy
|
||||
user_location.altitude = location_data.altitude
|
||||
user_location.speed = location_data.speed
|
||||
user_location.heading = location_data.heading
|
||||
else:
|
||||
user_location = UserLocation(
|
||||
user_id=current_user.id,
|
||||
latitude=location_data.latitude,
|
||||
longitude=location_data.longitude,
|
||||
accuracy=location_data.accuracy,
|
||||
altitude=location_data.altitude,
|
||||
speed=location_data.speed,
|
||||
heading=location_data.heading,
|
||||
)
|
||||
db.add(user_location)
|
||||
|
||||
# Save to history
|
||||
location_history = LocationHistory(
|
||||
user_id=current_user.id,
|
||||
latitude=location_data.latitude,
|
||||
longitude=location_data.longitude,
|
||||
accuracy=location_data.accuracy,
|
||||
recorded_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(location_history)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Cache location for fast access
|
||||
await CacheService.set_location(
|
||||
current_user.id,
|
||||
location_data.latitude,
|
||||
location_data.longitude,
|
||||
expire=300 # 5 minutes
|
||||
)
|
||||
|
||||
return {"message": "Location updated successfully"}
|
||||
|
||||
|
||||
@app.get("/api/v1/user-location/{user_id}", response_model=LocationResponse)
|
||||
async def get_user_location(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get specific user's location (if sharing is enabled)"""
|
||||
|
||||
# Check if requested user exists and has location sharing enabled
|
||||
result = await db.execute(select(User).filter(User.id == user_id))
|
||||
target_user = result.scalars().first()
|
||||
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if not target_user.location_sharing_enabled and target_user.id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="User has disabled location sharing")
|
||||
|
||||
# Try cache first
|
||||
cached_location = await CacheService.get_location(user_id)
|
||||
if cached_location:
|
||||
lat, lng = cached_location
|
||||
return LocationResponse(
|
||||
user_id=user_id,
|
||||
latitude=lat,
|
||||
longitude=lng,
|
||||
accuracy=None,
|
||||
updated_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
# Get from database
|
||||
result = await db.execute(
|
||||
select(UserLocation).filter(UserLocation.user_id == user_id)
|
||||
)
|
||||
user_location = result.scalars().first()
|
||||
|
||||
if not user_location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
return LocationResponse.model_validate(user_location)
|
||||
|
||||
|
||||
@app.get("/api/v1/nearby-users", response_model=List[NearbyUserResponse])
|
||||
async def get_nearby_users(
|
||||
latitude: float = Query(..., ge=-90, le=90),
|
||||
longitude: float = Query(..., ge=-180, le=180),
|
||||
radius_km: float = Query(1.0, ge=0.1, le=10.0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Find users within specified radius"""
|
||||
|
||||
# Convert radius to degrees (approximate)
|
||||
# 1 degree ≈ 111 km
|
||||
radius_deg = radius_km / 111.0
|
||||
|
||||
# Query for nearby users with location sharing enabled
|
||||
# Using bounding box for initial filtering (more efficient than distance calculation)
|
||||
query = text("""
|
||||
SELECT
|
||||
ul.user_id,
|
||||
ul.latitude,
|
||||
ul.longitude,
|
||||
ul.updated_at,
|
||||
u.location_sharing_enabled
|
||||
FROM user_locations ul
|
||||
JOIN users u ON ul.user_id = u.id
|
||||
WHERE u.location_sharing_enabled = true
|
||||
AND u.is_active = true
|
||||
AND ul.user_id != :current_user_id
|
||||
AND ul.latitude BETWEEN :lat_min AND :lat_max
|
||||
AND ul.longitude BETWEEN :lng_min AND :lng_max
|
||||
AND ul.updated_at > :time_threshold
|
||||
LIMIT :limit_val
|
||||
""")
|
||||
|
||||
time_threshold = datetime.utcnow() - timedelta(minutes=15) # Only recent locations
|
||||
|
||||
result = await db.execute(query, {
|
||||
"current_user_id": current_user.id,
|
||||
"lat_min": latitude - radius_deg,
|
||||
"lat_max": latitude + radius_deg,
|
||||
"lng_min": longitude - radius_deg,
|
||||
"lng_max": longitude + radius_deg,
|
||||
"time_threshold": time_threshold,
|
||||
"limit_val": limit
|
||||
})
|
||||
|
||||
nearby_users = []
|
||||
|
||||
for row in result:
|
||||
# Calculate exact distance
|
||||
distance = calculate_distance(
|
||||
latitude, longitude,
|
||||
row.latitude, row.longitude
|
||||
)
|
||||
|
||||
# Filter by exact radius
|
||||
if distance <= radius_km * 1000: # Convert km to meters
|
||||
nearby_users.append(NearbyUserResponse(
|
||||
user_id=row.user_id,
|
||||
latitude=row.latitude,
|
||||
longitude=row.longitude,
|
||||
distance_meters=distance,
|
||||
last_seen=row.updated_at
|
||||
))
|
||||
|
||||
# Sort by distance
|
||||
nearby_users.sort(key=lambda x: x.distance_meters)
|
||||
|
||||
return nearby_users
|
||||
|
||||
|
||||
@app.get("/api/v1/location-history")
|
||||
async def get_location_history(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
hours: int = Query(24, ge=1, le=168), # Max 1 week
|
||||
limit: int = Query(100, ge=1, le=1000)
|
||||
):
|
||||
"""Get user's location history"""
|
||||
|
||||
time_threshold = datetime.utcnow() - timedelta(hours=hours)
|
||||
|
||||
result = await db.execute(
|
||||
select(LocationHistory)
|
||||
.filter(
|
||||
LocationHistory.user_id == current_user.id,
|
||||
LocationHistory.recorded_at >= time_threshold
|
||||
)
|
||||
.order_by(LocationHistory.recorded_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
history = result.scalars().all()
|
||||
|
||||
return [
|
||||
{
|
||||
"latitude": entry.latitude,
|
||||
"longitude": entry.longitude,
|
||||
"accuracy": entry.accuracy,
|
||||
"recorded_at": entry.recorded_at
|
||||
}
|
||||
for entry in history
|
||||
]
|
||||
|
||||
|
||||
@app.delete("/api/v1/location")
|
||||
async def delete_user_location(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Delete user's current location"""
|
||||
|
||||
# Delete current location
|
||||
result = await db.execute(
|
||||
select(UserLocation).filter(UserLocation.user_id == current_user.id)
|
||||
)
|
||||
user_location = result.scalars().first()
|
||||
|
||||
if user_location:
|
||||
await db.delete(user_location)
|
||||
await db.commit()
|
||||
|
||||
# Clear cache
|
||||
await CacheService.delete(f"location:{current_user.id}")
|
||||
|
||||
return {"message": "Location deleted successfully"}
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "service": "location-service"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8003)
|
||||
46
services/location_service/models.py
Normal file
46
services/location_service/models.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from sqlalchemy import Column, Integer, Float, DateTime, ForeignKey, Index
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from shared.database import BaseModel
|
||||
import uuid
|
||||
|
||||
|
||||
class UserLocation(BaseModel):
|
||||
__tablename__ = "user_locations"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
latitude = Column(Float, nullable=False)
|
||||
longitude = Column(Float, nullable=False)
|
||||
accuracy = Column(Float) # GPS accuracy in meters
|
||||
altitude = Column(Float)
|
||||
speed = Column(Float) # Speed in m/s
|
||||
heading = Column(Float) # Direction in degrees
|
||||
|
||||
# Indexes for geospatial queries
|
||||
__table_args__ = (
|
||||
Index('idx_location_coords', 'latitude', 'longitude'),
|
||||
Index('idx_location_user_time', 'user_id', 'created_at'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<UserLocation user_id={self.user_id} lat={self.latitude} lng={self.longitude}>"
|
||||
|
||||
|
||||
class LocationHistory(BaseModel):
|
||||
__tablename__ = "location_history"
|
||||
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
latitude = Column(Float, nullable=False)
|
||||
longitude = Column(Float, nullable=False)
|
||||
accuracy = Column(Float)
|
||||
recorded_at = Column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
# Partition by date for better performance
|
||||
__table_args__ = (
|
||||
Index('idx_history_user_date', 'user_id', 'recorded_at'),
|
||||
Index('idx_history_coords_date', 'latitude', 'longitude', 'recorded_at'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<LocationHistory user_id={self.user_id} at={self.recorded_at}>"
|
||||
361
services/notification_service/main.py
Normal file
361
services/notification_service/main.py
Normal file
@@ -0,0 +1,361 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from shared.config import settings
|
||||
from shared.database import get_db
|
||||
from services.user_service.main import get_current_user
|
||||
from services.user_service.models import User
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
app = FastAPI(title="Notification Service", version="1.0.0")
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
class NotificationRequest(BaseModel):
|
||||
title: str = Field(..., max_length=100)
|
||||
body: str = Field(..., max_length=500)
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
priority: str = Field("normal", pattern="^(low|normal|high)$")
|
||||
|
||||
|
||||
class EmergencyNotificationRequest(BaseModel):
|
||||
alert_id: int
|
||||
user_ids: List[int]
|
||||
alert_type: Optional[str] = "general"
|
||||
location: Optional[str] = None
|
||||
|
||||
|
||||
class DeviceToken(BaseModel):
|
||||
token: str = Field(..., min_length=10)
|
||||
platform: str = Field(..., pattern="^(ios|android|web)$")
|
||||
|
||||
|
||||
class NotificationStats(BaseModel):
|
||||
total_sent: int
|
||||
successful_deliveries: int
|
||||
failed_deliveries: int
|
||||
emergency_notifications: int
|
||||
|
||||
|
||||
# Mock FCM client for demonstration
|
||||
class FCMClient:
|
||||
def __init__(self, server_key: str):
|
||||
self.server_key = server_key
|
||||
self.fcm_url = "https://fcm.googleapis.com/fcm/send"
|
||||
|
||||
async def send_notification(self, tokens: List[str], notification_data: dict) -> dict:
|
||||
"""Send push notification via FCM"""
|
||||
if not self.server_key:
|
||||
print("FCM Server Key not configured - notification would be sent")
|
||||
return {"success_count": len(tokens), "failure_count": 0}
|
||||
|
||||
headers = {
|
||||
"Authorization": f"key={self.server_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
payload = {
|
||||
"registration_ids": tokens,
|
||||
"notification": {
|
||||
"title": notification_data.get("title"),
|
||||
"body": notification_data.get("body"),
|
||||
"sound": "default"
|
||||
},
|
||||
"data": notification_data.get("data", {}),
|
||||
"priority": "high" if notification_data.get("priority") == "high" else "normal"
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.fcm_url,
|
||||
headers=headers,
|
||||
json=payload,
|
||||
timeout=10.0
|
||||
)
|
||||
result = response.json()
|
||||
return {
|
||||
"success_count": result.get("success", 0),
|
||||
"failure_count": result.get("failure", 0),
|
||||
"results": result.get("results", [])
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"FCM Error: {e}")
|
||||
return {"success_count": 0, "failure_count": len(tokens)}
|
||||
|
||||
|
||||
# Initialize FCM client
|
||||
fcm_client = FCMClient(settings.FCM_SERVER_KEY)
|
||||
|
||||
# In-memory storage for demo (use Redis or database in production)
|
||||
user_device_tokens: Dict[int, List[str]] = {}
|
||||
notification_stats = {
|
||||
"total_sent": 0,
|
||||
"successful_deliveries": 0,
|
||||
"failed_deliveries": 0,
|
||||
"emergency_notifications": 0
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/register-device")
|
||||
async def register_device_token(
|
||||
device_data: DeviceToken,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Register device token for push notifications"""
|
||||
|
||||
if current_user.id not in user_device_tokens:
|
||||
user_device_tokens[current_user.id] = []
|
||||
|
||||
# Remove existing token if present
|
||||
if device_data.token in user_device_tokens[current_user.id]:
|
||||
user_device_tokens[current_user.id].remove(device_data.token)
|
||||
|
||||
# Add new token
|
||||
user_device_tokens[current_user.id].append(device_data.token)
|
||||
|
||||
# Keep only last 3 tokens per user
|
||||
user_device_tokens[current_user.id] = user_device_tokens[current_user.id][-3:]
|
||||
|
||||
return {"message": "Device token registered successfully"}
|
||||
|
||||
|
||||
@app.post("/api/v1/send-notification")
|
||||
async def send_notification(
|
||||
notification: NotificationRequest,
|
||||
target_user_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Send notification to specific user"""
|
||||
|
||||
# Check if target user exists and accepts notifications
|
||||
result = await db.execute(select(User).filter(User.id == target_user_id))
|
||||
target_user = result.scalars().first()
|
||||
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="Target user not found")
|
||||
|
||||
if not target_user.push_notifications_enabled:
|
||||
raise HTTPException(status_code=403, detail="User has disabled push notifications")
|
||||
|
||||
# Get user's device tokens
|
||||
tokens = user_device_tokens.get(target_user_id, [])
|
||||
if not tokens:
|
||||
raise HTTPException(status_code=400, detail="No device tokens found for user")
|
||||
|
||||
# Send notification in background
|
||||
background_tasks.add_task(
|
||||
send_push_notification,
|
||||
tokens,
|
||||
{
|
||||
"title": notification.title,
|
||||
"body": notification.body,
|
||||
"data": notification.data or {},
|
||||
"priority": notification.priority
|
||||
}
|
||||
)
|
||||
|
||||
return {"message": "Notification queued for delivery"}
|
||||
|
||||
|
||||
@app.post("/api/v1/send-emergency-notifications")
|
||||
async def send_emergency_notifications(
|
||||
emergency_data: EmergencyNotificationRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Send emergency notifications to nearby users"""
|
||||
|
||||
if not emergency_data.user_ids:
|
||||
return {"message": "No users to notify"}
|
||||
|
||||
# Get users who have emergency notifications enabled
|
||||
result = await db.execute(
|
||||
select(User).filter(
|
||||
User.id.in_(emergency_data.user_ids),
|
||||
User.emergency_notifications_enabled == True,
|
||||
User.is_active == True
|
||||
)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
# Collect all device tokens
|
||||
all_tokens = []
|
||||
for user in users:
|
||||
tokens = user_device_tokens.get(user.id, [])
|
||||
all_tokens.extend(tokens)
|
||||
|
||||
if not all_tokens:
|
||||
return {"message": "No device tokens found for target users"}
|
||||
|
||||
# Prepare emergency notification
|
||||
emergency_title = "🚨 Emergency Alert Nearby"
|
||||
emergency_body = f"Someone needs help in your area. Alert type: {emergency_data.alert_type}"
|
||||
|
||||
if emergency_data.location:
|
||||
emergency_body += f" Location: {emergency_data.location}"
|
||||
|
||||
notification_data = {
|
||||
"title": emergency_title,
|
||||
"body": emergency_body,
|
||||
"data": {
|
||||
"type": "emergency",
|
||||
"alert_id": str(emergency_data.alert_id),
|
||||
"alert_type": emergency_data.alert_type
|
||||
},
|
||||
"priority": "high"
|
||||
}
|
||||
|
||||
# Send notifications in background
|
||||
background_tasks.add_task(
|
||||
send_emergency_push_notification,
|
||||
all_tokens,
|
||||
notification_data
|
||||
)
|
||||
|
||||
return {"message": f"Emergency notifications queued for {len(users)} users"}
|
||||
|
||||
|
||||
async def send_push_notification(tokens: List[str], notification_data: dict):
|
||||
"""Send push notification using FCM"""
|
||||
try:
|
||||
result = await fcm_client.send_notification(tokens, notification_data)
|
||||
|
||||
# Update stats
|
||||
notification_stats["total_sent"] += len(tokens)
|
||||
notification_stats["successful_deliveries"] += result["success_count"]
|
||||
notification_stats["failed_deliveries"] += result["failure_count"]
|
||||
|
||||
print(f"Notification sent: {result['success_count']} successful, {result['failure_count']} failed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to send notification: {e}")
|
||||
notification_stats["failed_deliveries"] += len(tokens)
|
||||
|
||||
|
||||
async def send_emergency_push_notification(tokens: List[str], notification_data: dict):
|
||||
"""Send emergency push notification with special handling"""
|
||||
try:
|
||||
# Emergency notifications are sent immediately with high priority
|
||||
result = await fcm_client.send_notification(tokens, notification_data)
|
||||
|
||||
# Update stats
|
||||
notification_stats["total_sent"] += len(tokens)
|
||||
notification_stats["successful_deliveries"] += result["success_count"]
|
||||
notification_stats["failed_deliveries"] += result["failure_count"]
|
||||
notification_stats["emergency_notifications"] += len(tokens)
|
||||
|
||||
print(f"Emergency notification sent: {result['success_count']} successful, {result['failure_count']} failed")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to send emergency notification: {e}")
|
||||
notification_stats["emergency_notifications"] += len(tokens)
|
||||
notification_stats["failed_deliveries"] += len(tokens)
|
||||
|
||||
|
||||
@app.post("/api/v1/send-calendar-reminder")
|
||||
async def send_calendar_reminder(
|
||||
title: str,
|
||||
message: str,
|
||||
user_ids: List[int],
|
||||
background_tasks: BackgroundTasks,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Send calendar reminder notifications"""
|
||||
|
||||
# Get users who have notifications enabled
|
||||
result = await db.execute(
|
||||
select(User).filter(
|
||||
User.id.in_(user_ids),
|
||||
User.push_notifications_enabled == True,
|
||||
User.is_active == True
|
||||
)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
# Send notifications to each user
|
||||
for user in users:
|
||||
tokens = user_device_tokens.get(user.id, [])
|
||||
if tokens:
|
||||
background_tasks.add_task(
|
||||
send_push_notification,
|
||||
tokens,
|
||||
{
|
||||
"title": title,
|
||||
"body": message,
|
||||
"data": {"type": "calendar_reminder"},
|
||||
"priority": "normal"
|
||||
}
|
||||
)
|
||||
|
||||
return {"message": f"Calendar reminders queued for {len(users)} users"}
|
||||
|
||||
|
||||
@app.delete("/api/v1/device-token")
|
||||
async def unregister_device_token(
|
||||
token: str,
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Unregister device token"""
|
||||
|
||||
if current_user.id in user_device_tokens:
|
||||
tokens = user_device_tokens[current_user.id]
|
||||
if token in tokens:
|
||||
tokens.remove(token)
|
||||
if not tokens:
|
||||
del user_device_tokens[current_user.id]
|
||||
|
||||
return {"message": "Device token unregistered successfully"}
|
||||
|
||||
|
||||
@app.get("/api/v1/my-devices")
|
||||
async def get_my_device_tokens(
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get user's registered device tokens (masked for security)"""
|
||||
|
||||
tokens = user_device_tokens.get(current_user.id, [])
|
||||
masked_tokens = [f"{token[:8]}...{token[-8:]}" for token in tokens]
|
||||
|
||||
return {
|
||||
"device_count": len(tokens),
|
||||
"tokens": masked_tokens
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/stats", response_model=NotificationStats)
|
||||
async def get_notification_stats(current_user: User = Depends(get_current_user)):
|
||||
"""Get notification service statistics"""
|
||||
|
||||
return NotificationStats(**notification_stats)
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "notification-service",
|
||||
"fcm_configured": bool(settings.FCM_SERVER_KEY)
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8005)
|
||||
140
services/user_service/main.py
Normal file
140
services/user_service/main.py
Normal file
@@ -0,0 +1,140 @@
|
||||
from fastapi import FastAPI, HTTPException, Depends, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from datetime import timedelta
|
||||
from shared.config import settings
|
||||
from shared.database import get_db
|
||||
from shared.auth import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
get_current_user_from_token
|
||||
)
|
||||
from services.user_service.models import User
|
||||
from services.user_service.schemas import UserCreate, UserResponse, UserLogin, Token, UserUpdate
|
||||
|
||||
app = FastAPI(title="User Service", version="1.0.0")
|
||||
|
||||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # В продакшене ограничить конкретными доменами
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
user_data: dict = Depends(get_current_user_from_token),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Get current user from token via auth dependency."""
|
||||
# Get full user object from database
|
||||
result = await db.execute(select(User).filter(User.id == user_data["user_id"]))
|
||||
user = result.scalars().first()
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "service": "user_service"}
|
||||
|
||||
|
||||
@app.post("/api/v1/register", response_model=UserResponse)
|
||||
async def register_user(user_data: UserCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""Register a new user"""
|
||||
# Check if user already exists
|
||||
result = await db.execute(select(User).filter(User.email == user_data.email))
|
||||
if result.scalars().first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Create new user
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
db_user = User(
|
||||
email=user_data.email,
|
||||
phone=user_data.phone,
|
||||
password_hash=hashed_password,
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
date_of_birth=user_data.date_of_birth,
|
||||
bio=user_data.bio,
|
||||
)
|
||||
|
||||
db.add(db_user)
|
||||
await db.commit()
|
||||
await db.refresh(db_user)
|
||||
|
||||
return UserResponse.model_validate(db_user)
|
||||
|
||||
|
||||
@app.post("/api/v1/login", response_model=Token)
|
||||
async def login(user_credentials: UserLogin, db: AsyncSession = Depends(get_db)):
|
||||
"""Authenticate user and return token"""
|
||||
result = await db.execute(select(User).filter(User.email == user_credentials.email))
|
||||
user = result.scalars().first()
|
||||
|
||||
if not user or not verify_password(user_credentials.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Account is deactivated",
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": str(user.id), "email": user.email},
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
|
||||
|
||||
@app.get("/api/v1/profile", response_model=UserResponse)
|
||||
async def get_profile(current_user: User = Depends(get_current_user)):
|
||||
"""Get current user profile"""
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@app.put("/api/v1/profile", response_model=UserResponse)
|
||||
async def update_profile(
|
||||
user_update: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Update user profile"""
|
||||
update_data = user_update.model_dump(exclude_unset=True)
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(current_user, field, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {"status": "healthy", "service": "user-service"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
39
services/user_service/models.py
Normal file
39
services/user_service/models.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy import Column, String, Integer, Date, Text, Boolean
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from shared.database import BaseModel
|
||||
import uuid
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
__tablename__ = "users"
|
||||
|
||||
uuid = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
phone = Column(String, unique=True, index=True)
|
||||
password_hash = Column(String, nullable=False)
|
||||
|
||||
# Profile information
|
||||
first_name = Column(String(50), nullable=False)
|
||||
last_name = Column(String(50), nullable=False)
|
||||
date_of_birth = Column(Date)
|
||||
avatar_url = Column(String)
|
||||
bio = Column(Text)
|
||||
|
||||
# Emergency contacts
|
||||
emergency_contact_1_name = Column(String(100))
|
||||
emergency_contact_1_phone = Column(String(20))
|
||||
emergency_contact_2_name = Column(String(100))
|
||||
emergency_contact_2_phone = Column(String(20))
|
||||
|
||||
# Settings
|
||||
location_sharing_enabled = Column(Boolean, default=True)
|
||||
emergency_notifications_enabled = Column(Boolean, default=True)
|
||||
push_notifications_enabled = Column(Boolean, default=True)
|
||||
|
||||
# Security
|
||||
email_verified = Column(Boolean, default=False)
|
||||
phone_verified = Column(Boolean, default=False)
|
||||
is_blocked = Column(Boolean, default=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.email}>"
|
||||
77
services/user_service/schemas.py
Normal file
77
services/user_service/schemas.py
Normal file
@@ -0,0 +1,77 @@
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
from typing import Optional
|
||||
from datetime import date
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
email: EmailStr
|
||||
phone: Optional[str] = None
|
||||
first_name: str = Field(..., min_length=1, max_length=50)
|
||||
last_name: str = Field(..., min_length=1, max_length=50)
|
||||
date_of_birth: Optional[date] = None
|
||||
bio: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
password: str = Field(..., min_length=8, max_length=100)
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
first_name: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||
last_name: Optional[str] = Field(None, min_length=1, max_length=50)
|
||||
phone: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
bio: Optional[str] = Field(None, max_length=500)
|
||||
avatar_url: Optional[str] = None
|
||||
|
||||
# Emergency contacts
|
||||
emergency_contact_1_name: Optional[str] = Field(None, max_length=100)
|
||||
emergency_contact_1_phone: Optional[str] = Field(None, max_length=20)
|
||||
emergency_contact_2_name: Optional[str] = Field(None, max_length=100)
|
||||
emergency_contact_2_phone: Optional[str] = Field(None, max_length=20)
|
||||
|
||||
# Settings
|
||||
location_sharing_enabled: Optional[bool] = None
|
||||
emergency_notifications_enabled: Optional[bool] = None
|
||||
push_notifications_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class UserResponse(UserBase):
|
||||
id: int
|
||||
uuid: str
|
||||
avatar_url: Optional[str] = None
|
||||
emergency_contact_1_name: Optional[str] = None
|
||||
emergency_contact_1_phone: Optional[str] = None
|
||||
emergency_contact_2_name: Optional[str] = None
|
||||
emergency_contact_2_phone: Optional[str] = None
|
||||
location_sharing_enabled: bool
|
||||
emergency_notifications_enabled: bool
|
||||
push_notifications_enabled: bool
|
||||
email_verified: bool
|
||||
phone_verified: bool
|
||||
is_active: bool
|
||||
|
||||
@field_validator('uuid', mode='before')
|
||||
@classmethod
|
||||
def convert_uuid_to_str(cls, v):
|
||||
if isinstance(v, UUID):
|
||||
return str(v)
|
||||
return v
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
|
||||
class TokenData(BaseModel):
|
||||
email: Optional[str] = None
|
||||
Reference in New Issue
Block a user