This commit is contained in:
2025-12-18 21:13:49 +03:00
parent 84b934036b
commit 030af7ca83
45 changed files with 3106 additions and 0 deletions

View File

View File

@@ -0,0 +1,16 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql://postgres:postgres@localhost:5432/transport"
# Event detection settings
long_stop_minutes: int = 5
overspeed_limit: float = 60.0 # km/h
connection_lost_minutes: int = 5
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -0,0 +1,23 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
# Convert postgresql:// to postgresql+asyncpg://
database_url = settings.database_url.replace("postgresql://", "postgresql+asyncpg://")
engine = create_async_engine(database_url, echo=False)
async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
class Base(DeclarativeBase):
pass
async def get_db() -> AsyncSession:
async with async_session_maker() as session:
try:
yield session
finally:
await session.close()

View File

@@ -0,0 +1,50 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import vehicles, positions, events
from app.websocket import router as ws_router
from app.services.connection_checker import connection_checker_loop
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: запуск фоновых задач
task = asyncio.create_task(connection_checker_loop())
yield
# Shutdown: остановка фоновых задач
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(
title="Transport Monitoring API",
description="API для системы мониторинга транспорта",
version="1.0.0",
lifespan=lifespan
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include routers
app.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"])
app.include_router(positions.router, tags=["positions"])
app.include_router(events.router, prefix="/events", tags=["events"])
app.include_router(ws_router)
@app.get("/health")
async def health_check():
return {"status": "ok"}

View File

@@ -0,0 +1,5 @@
from app.models.vehicle import Vehicle
from app.models.position import Position
from app.models.event import Event
__all__ = ["Vehicle", "Position", "Event"]

View File

@@ -0,0 +1,24 @@
from datetime import datetime
from sqlalchemy import DateTime, String, ForeignKey, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Event(Base):
__tablename__ = "events"
id: Mapped[int] = mapped_column(primary_key=True)
vehicle_id: Mapped[int] = mapped_column(ForeignKey("vehicles.id", ondelete="CASCADE"))
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
type: Mapped[str] = mapped_column(String(50), nullable=False) # LONG_STOP, OVERSPEED, CONNECTION_LOST
payload: Mapped[dict] = mapped_column(JSONB, default=dict)
# Relationships
vehicle: Mapped["Vehicle"] = relationship(back_populates="events")
__table_args__ = (
Index("idx_events_vehicle_ts", "vehicle_id", "timestamp"),
Index("idx_events_type", "type"),
)

View File

@@ -0,0 +1,24 @@
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Position(Base):
__tablename__ = "positions"
id: Mapped[int] = mapped_column(primary_key=True)
vehicle_id: Mapped[int] = mapped_column(ForeignKey("vehicles.id", ondelete="CASCADE"))
timestamp: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=datetime.utcnow)
lat: Mapped[float] = mapped_column(Float, nullable=False)
lon: Mapped[float] = mapped_column(Float, nullable=False)
speed: Mapped[float] = mapped_column(Float, default=0.0) # km/h
heading: Mapped[float] = mapped_column(Float, default=0.0) # 0-360 degrees
# Relationships
vehicle: Mapped["Vehicle"] = relationship(back_populates="positions")
__table_args__ = (
Index("idx_positions_vehicle_ts", "vehicle_id", "timestamp"),
)

View File

@@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import String, DateTime
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
class Vehicle(Base):
__tablename__ = "vehicles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
type: Mapped[str] = mapped_column(String(50), default="car") # car, bus, truck
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
# Relationships
positions: Mapped[list["Position"]] = relationship(back_populates="vehicle", cascade="all, delete-orphan")
events: Mapped[list["Event"]] = relationship(back_populates="vehicle", cascade="all, delete-orphan")

View File

@@ -0,0 +1,61 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Event
from app.schemas import EventResponse
router = APIRouter()
@router.get("", response_model=list[EventResponse])
async def get_events(
type: Optional[str] = None,
vehicle_id: Optional[int] = None,
from_time: Optional[datetime] = Query(None, alias="from"),
to_time: Optional[datetime] = Query(None, alias="to"),
limit: int = Query(100, le=1000),
db: AsyncSession = Depends(get_db)
):
"""Получить список событий"""
query = select(Event)
if type:
query = query.where(Event.type == type)
if vehicle_id:
query = query.where(Event.vehicle_id == vehicle_id)
if from_time:
query = query.where(Event.timestamp >= from_time)
if to_time:
query = query.where(Event.timestamp <= to_time)
query = query.order_by(desc(Event.timestamp)).limit(limit)
result = await db.execute(query)
events = result.scalars().all()
return events
@router.get("/vehicles/{vehicle_id}/events", response_model=list[EventResponse])
async def get_vehicle_events(
vehicle_id: int,
type: Optional[str] = None,
limit: int = Query(100, le=1000),
db: AsyncSession = Depends(get_db)
):
"""Получить события конкретного транспортного средства"""
query = select(Event).where(Event.vehicle_id == vehicle_id)
if type:
query = query.where(Event.type == type)
query = query.order_by(desc(Event.timestamp)).limit(limit)
result = await db.execute(query)
events = result.scalars().all()
return events

View File

@@ -0,0 +1,103 @@
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, desc, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Vehicle, Position
from app.schemas import PositionResponse, PositionIngest
from app.services.websocket_manager import manager
from app.services.event_detector import detect_events
router = APIRouter()
@router.get("/vehicles/{vehicle_id}/positions", response_model=list[PositionResponse])
async def get_vehicle_positions(
vehicle_id: int,
from_time: Optional[datetime] = Query(None, alias="from"),
to_time: Optional[datetime] = Query(None, alias="to"),
limit: int = Query(1000, le=10000),
db: AsyncSession = Depends(get_db)
):
"""Получить историю позиций транспортного средства"""
# Check vehicle exists
result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id))
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Vehicle not found")
# Build query
query = select(Position).where(Position.vehicle_id == vehicle_id)
if from_time:
query = query.where(Position.timestamp >= from_time)
if to_time:
query = query.where(Position.timestamp <= to_time)
query = query.order_by(desc(Position.timestamp)).limit(limit)
result = await db.execute(query)
positions = result.scalars().all()
return positions
@router.get("/vehicles/{vehicle_id}/last-position", response_model=Optional[PositionResponse])
async def get_last_position(vehicle_id: int, db: AsyncSession = Depends(get_db)):
"""Получить последнюю позицию транспортного средства"""
result = await db.execute(
select(Position)
.where(Position.vehicle_id == vehicle_id)
.order_by(desc(Position.timestamp))
.limit(1)
)
position = result.scalar_one_or_none()
if not position:
return None
return position
@router.post("/ingest/position", response_model=PositionResponse, status_code=201)
async def ingest_position(position: PositionIngest, db: AsyncSession = Depends(get_db)):
"""Принять новую позицию от трекера/симулятора"""
# Check vehicle exists
result = await db.execute(select(Vehicle).where(Vehicle.id == position.vehicle_id))
vehicle = result.scalar_one_or_none()
if not vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
# Get previous position for event detection
prev_result = await db.execute(
select(Position)
.where(Position.vehicle_id == position.vehicle_id)
.order_by(desc(Position.timestamp))
.limit(1)
)
prev_position = prev_result.scalar_one_or_none()
# Create new position
db_position = Position(
vehicle_id=position.vehicle_id,
timestamp=position.timestamp or datetime.utcnow(),
lat=position.lat,
lon=position.lon,
speed=position.speed,
heading=position.heading
)
db.add(db_position)
await db.commit()
await db.refresh(db_position)
# Detect events
events = await detect_events(db, vehicle, db_position, prev_position)
# Broadcast to WebSocket clients
await manager.broadcast_position(db_position)
for event in events:
await manager.broadcast_event(event)
return db_position

View File

@@ -0,0 +1,139 @@
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Vehicle, Position
from app.schemas import VehicleCreate, VehicleUpdate, VehicleResponse, VehicleWithPosition
from app.schemas.vehicle import LastPosition
router = APIRouter()
def get_vehicle_status(last_position: Optional[Position], now: datetime) -> str:
if not last_position:
return "offline"
time_diff = now - last_position.timestamp
if time_diff > timedelta(minutes=5):
return "offline"
elif last_position.speed < 2:
return "stopped"
else:
return "moving"
@router.get("", response_model=list[VehicleWithPosition])
async def get_vehicles(db: AsyncSession = Depends(get_db)):
"""Получить список всех транспортных средств с последними позициями"""
result = await db.execute(select(Vehicle))
vehicles = result.scalars().all()
response = []
now = datetime.utcnow()
for vehicle in vehicles:
# Get last position
pos_result = await db.execute(
select(Position)
.where(Position.vehicle_id == vehicle.id)
.order_by(desc(Position.timestamp))
.limit(1)
)
last_pos = pos_result.scalar_one_or_none()
vehicle_data = VehicleWithPosition(
id=vehicle.id,
name=vehicle.name,
type=vehicle.type,
created_at=vehicle.created_at,
last_position=LastPosition(
lat=last_pos.lat,
lon=last_pos.lon,
speed=last_pos.speed,
heading=last_pos.heading,
timestamp=last_pos.timestamp
) if last_pos else None,
status=get_vehicle_status(last_pos, now)
)
response.append(vehicle_data)
return response
@router.get("/{vehicle_id}", response_model=VehicleWithPosition)
async def get_vehicle(vehicle_id: int, db: AsyncSession = Depends(get_db)):
"""Получить информацию о транспортном средстве"""
result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id))
vehicle = result.scalar_one_or_none()
if not vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
# Get last position
pos_result = await db.execute(
select(Position)
.where(Position.vehicle_id == vehicle.id)
.order_by(desc(Position.timestamp))
.limit(1)
)
last_pos = pos_result.scalar_one_or_none()
now = datetime.utcnow()
return VehicleWithPosition(
id=vehicle.id,
name=vehicle.name,
type=vehicle.type,
created_at=vehicle.created_at,
last_position=LastPosition(
lat=last_pos.lat,
lon=last_pos.lon,
speed=last_pos.speed,
heading=last_pos.heading,
timestamp=last_pos.timestamp
) if last_pos else None,
status=get_vehicle_status(last_pos, now)
)
@router.post("", response_model=VehicleResponse, status_code=201)
async def create_vehicle(vehicle: VehicleCreate, db: AsyncSession = Depends(get_db)):
"""Создать новое транспортное средство"""
db_vehicle = Vehicle(**vehicle.model_dump())
db.add(db_vehicle)
await db.commit()
await db.refresh(db_vehicle)
return db_vehicle
@router.put("/{vehicle_id}", response_model=VehicleResponse)
async def update_vehicle(vehicle_id: int, vehicle: VehicleUpdate, db: AsyncSession = Depends(get_db)):
"""Обновить транспортное средство"""
result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id))
db_vehicle = result.scalar_one_or_none()
if not db_vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
update_data = vehicle.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_vehicle, field, value)
await db.commit()
await db.refresh(db_vehicle)
return db_vehicle
@router.delete("/{vehicle_id}", status_code=204)
async def delete_vehicle(vehicle_id: int, db: AsyncSession = Depends(get_db)):
"""Удалить транспортное средство"""
result = await db.execute(select(Vehicle).where(Vehicle.id == vehicle_id))
db_vehicle = result.scalar_one_or_none()
if not db_vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
await db.delete(db_vehicle)
await db.commit()

View File

@@ -0,0 +1,9 @@
from app.schemas.vehicle import VehicleCreate, VehicleUpdate, VehicleResponse, VehicleWithPosition
from app.schemas.position import PositionCreate, PositionResponse, PositionIngest
from app.schemas.event import EventResponse
__all__ = [
"VehicleCreate", "VehicleUpdate", "VehicleResponse", "VehicleWithPosition",
"PositionCreate", "PositionResponse", "PositionIngest",
"EventResponse"
]

View File

@@ -0,0 +1,14 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel
class EventResponse(BaseModel):
id: int
vehicle_id: int
timestamp: datetime
type: str
payload: dict[str, Any]
class Config:
from_attributes = True

View File

@@ -0,0 +1,29 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class PositionBase(BaseModel):
lat: float
lon: float
speed: float = 0.0
heading: float = 0.0
class PositionCreate(PositionBase):
vehicle_id: int
timestamp: Optional[datetime] = None
class PositionIngest(PositionBase):
vehicle_id: int
timestamp: Optional[datetime] = None
class PositionResponse(PositionBase):
id: int
vehicle_id: int
timestamp: datetime
class Config:
from_attributes = True

View File

@@ -0,0 +1,38 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class VehicleBase(BaseModel):
name: str
type: str = "car"
class VehicleCreate(VehicleBase):
pass
class VehicleUpdate(BaseModel):
name: Optional[str] = None
type: Optional[str] = None
class VehicleResponse(VehicleBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class LastPosition(BaseModel):
lat: float
lon: float
speed: float
heading: float
timestamp: datetime
class VehicleWithPosition(VehicleResponse):
last_position: Optional[LastPosition] = None
status: str = "unknown" # moving, stopped, offline

View File

@@ -0,0 +1,83 @@
"""
Фоновая задача для проверки потери связи с объектами.
Создаёт события CONNECTION_LOST если нет данных более N минут.
"""
import asyncio
from datetime import datetime, timedelta
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import async_session_maker
from app.models import Vehicle, Position, Event
from app.config import settings
from app.services.websocket_manager import manager
async def check_connections():
"""Проверить все объекты на потерю связи"""
async with async_session_maker() as db:
# Получить все объекты
result = await db.execute(select(Vehicle))
vehicles = result.scalars().all()
now = datetime.utcnow()
threshold = now - timedelta(minutes=settings.connection_lost_minutes)
for vehicle in vehicles:
# Получить последнюю позицию
pos_result = await db.execute(
select(Position)
.where(Position.vehicle_id == vehicle.id)
.order_by(desc(Position.timestamp))
.limit(1)
)
last_pos = pos_result.scalar_one_or_none()
if not last_pos:
continue
# Проверить, прошло ли достаточно времени
if last_pos.timestamp < threshold:
# Проверить, не было ли уже события CONNECTION_LOST за последние N минут
event_result = await db.execute(
select(Event)
.where(Event.vehicle_id == vehicle.id)
.where(Event.type == "CONNECTION_LOST")
.where(Event.timestamp > threshold)
.limit(1)
)
existing_event = event_result.scalar_one_or_none()
if not existing_event:
# Создать событие
minutes_ago = (now - last_pos.timestamp).total_seconds() / 60
event = Event(
vehicle_id=vehicle.id,
timestamp=now,
type="CONNECTION_LOST",
payload={
"last_seen": last_pos.timestamp.isoformat(),
"minutes_ago": round(minutes_ago, 1),
"lat": last_pos.lat,
"lon": last_pos.lon
}
)
db.add(event)
await db.commit()
await db.refresh(event)
# Отправить через WebSocket
await manager.broadcast_event(event)
print(f"⚠️ CONNECTION_LOST: {vehicle.name} (нет данных {minutes_ago:.0f} мин)")
async def connection_checker_loop():
"""Бесконечный цикл проверки соединений"""
print("🔍 Connection checker started")
while True:
try:
await check_connections()
except Exception as e:
print(f"Connection checker error: {e}")
await asyncio.sleep(60) # Проверять каждую минуту

View File

@@ -0,0 +1,55 @@
from datetime import datetime, timedelta
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Vehicle, Position, Event
from app.config import settings
async def detect_events(
db: AsyncSession,
vehicle: Vehicle,
current: Position,
previous: Optional[Position]
) -> list[Event]:
"""Обнаружение событий на основе новой позиции"""
events = []
# Check for overspeed
if current.speed > settings.overspeed_limit:
event = Event(
vehicle_id=vehicle.id,
timestamp=current.timestamp,
type="OVERSPEED",
payload={
"speed": current.speed,
"limit": settings.overspeed_limit,
"lat": current.lat,
"lon": current.lon
}
)
db.add(event)
await db.commit()
await db.refresh(event)
events.append(event)
# Check for long stop (if speed is 0 and was 0 for a while)
if previous and current.speed < 2 and previous.speed < 2:
time_diff = current.timestamp - previous.timestamp
if time_diff >= timedelta(minutes=settings.long_stop_minutes):
event = Event(
vehicle_id=vehicle.id,
timestamp=current.timestamp,
type="LONG_STOP",
payload={
"duration_minutes": time_diff.total_seconds() / 60,
"lat": current.lat,
"lon": current.lon
}
)
db.add(event)
await db.commit()
await db.refresh(event)
events.append(event)
return events

View File

@@ -0,0 +1,63 @@
import json
from datetime import datetime
from typing import Set
from fastapi import WebSocket
from app.models import Position, Event
class ConnectionManager:
def __init__(self):
self.active_connections: Set[WebSocket] = set()
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.add(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.discard(websocket)
async def broadcast(self, message: dict):
"""Отправить сообщение всем подключенным клиентам"""
disconnected = set()
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception:
disconnected.add(connection)
# Remove disconnected clients
self.active_connections -= disconnected
async def broadcast_position(self, position: Position):
"""Отправить обновление позиции"""
message = {
"type": "position_update",
"data": {
"vehicle_id": position.vehicle_id,
"lat": position.lat,
"lon": position.lon,
"speed": position.speed,
"heading": position.heading,
"timestamp": position.timestamp.isoformat()
}
}
await self.broadcast(message)
async def broadcast_event(self, event: Event):
"""Отправить событие"""
message = {
"type": "event",
"data": {
"id": event.id,
"vehicle_id": event.vehicle_id,
"type": event.type,
"payload": event.payload,
"timestamp": event.timestamp.isoformat()
}
}
await self.broadcast(message)
# Global instance
manager = ConnectionManager()

View File

@@ -0,0 +1,18 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.services.websocket_manager import manager
router = APIRouter()
@router.websocket("/ws/positions")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket эндпоинт для получения обновлений позиций в реальном времени"""
await manager.connect(websocket)
try:
while True:
# Keep connection alive, wait for messages (ping/pong)
data = await websocket.receive_text()
# Can handle client messages here if needed
except WebSocketDisconnect:
manager.disconnect(websocket)