init
This commit is contained in:
18
transport/backend/Dockerfile
Normal file
18
transport/backend/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Run migrations and start server
|
||||
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"]
|
||||
42
transport/backend/alembic.ini
Normal file
42
transport/backend/alembic.ini
Normal file
@@ -0,0 +1,42 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
sqlalchemy.url = postgresql://postgres:postgres@postgres:5432/transport
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
61
transport/backend/alembic/env.py
Normal file
61
transport/backend/alembic/env.py
Normal file
@@ -0,0 +1,61 @@
|
||||
import os
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import models to ensure they are registered with Base
|
||||
from app.database import Base
|
||||
from app.models import Vehicle, Position, Event
|
||||
|
||||
config = context.config
|
||||
|
||||
# Override sqlalchemy.url from environment if available
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if database_url:
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode."""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode."""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
transport/backend/alembic/script.py.mako
Normal file
26
transport/backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
75
transport/backend/alembic/versions/001_initial.py
Normal file
75
transport/backend/alembic/versions/001_initial.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2025-12-18
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create vehicles table
|
||||
op.create_table(
|
||||
'vehicles',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(100), nullable=False),
|
||||
sa.Column('type', sa.String(50), nullable=True, default='car'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create positions table
|
||||
op.create_table(
|
||||
'positions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('vehicle_id', sa.Integer(), nullable=False),
|
||||
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||
sa.Column('lat', sa.Float(), nullable=False),
|
||||
sa.Column('lon', sa.Float(), nullable=False),
|
||||
sa.Column('speed', sa.Float(), nullable=True, default=0.0),
|
||||
sa.Column('heading', sa.Float(), nullable=True, default=0.0),
|
||||
sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_positions_vehicle_ts', 'positions', ['vehicle_id', 'timestamp'])
|
||||
|
||||
# Create events table
|
||||
op.create_table(
|
||||
'events',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('vehicle_id', sa.Integer(), nullable=False),
|
||||
sa.Column('timestamp', sa.DateTime(), nullable=False),
|
||||
sa.Column('type', sa.String(50), nullable=False),
|
||||
sa.Column('payload', postgresql.JSONB(), nullable=True, default={}),
|
||||
sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_events_vehicle_ts', 'events', ['vehicle_id', 'timestamp'])
|
||||
op.create_index('idx_events_type', 'events', ['type'])
|
||||
|
||||
# Insert demo vehicles
|
||||
op.execute("""
|
||||
INSERT INTO vehicles (name, type, created_at) VALUES
|
||||
('Автобус А-101', 'bus', NOW()),
|
||||
('Автобус А-102', 'bus', NOW()),
|
||||
('Грузовик Г-201', 'truck', NOW()),
|
||||
('Легковой Л-301', 'car', NOW()),
|
||||
('Легковой Л-302', 'car', NOW())
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('events')
|
||||
op.drop_table('positions')
|
||||
op.drop_table('vehicles')
|
||||
0
transport/backend/app/__init__.py
Normal file
0
transport/backend/app/__init__.py
Normal file
16
transport/backend/app/config.py
Normal file
16
transport/backend/app/config.py
Normal 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()
|
||||
23
transport/backend/app/database.py
Normal file
23
transport/backend/app/database.py
Normal 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()
|
||||
50
transport/backend/app/main.py
Normal file
50
transport/backend/app/main.py
Normal 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"}
|
||||
5
transport/backend/app/models/__init__.py
Normal file
5
transport/backend/app/models/__init__.py
Normal 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"]
|
||||
24
transport/backend/app/models/event.py
Normal file
24
transport/backend/app/models/event.py
Normal 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"),
|
||||
)
|
||||
24
transport/backend/app/models/position.py
Normal file
24
transport/backend/app/models/position.py
Normal 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"),
|
||||
)
|
||||
18
transport/backend/app/models/vehicle.py
Normal file
18
transport/backend/app/models/vehicle.py
Normal 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")
|
||||
0
transport/backend/app/routers/__init__.py
Normal file
0
transport/backend/app/routers/__init__.py
Normal file
61
transport/backend/app/routers/events.py
Normal file
61
transport/backend/app/routers/events.py
Normal 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
|
||||
103
transport/backend/app/routers/positions.py
Normal file
103
transport/backend/app/routers/positions.py
Normal 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
|
||||
139
transport/backend/app/routers/vehicles.py
Normal file
139
transport/backend/app/routers/vehicles.py
Normal 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()
|
||||
9
transport/backend/app/schemas/__init__.py
Normal file
9
transport/backend/app/schemas/__init__.py
Normal 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"
|
||||
]
|
||||
14
transport/backend/app/schemas/event.py
Normal file
14
transport/backend/app/schemas/event.py
Normal 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
|
||||
29
transport/backend/app/schemas/position.py
Normal file
29
transport/backend/app/schemas/position.py
Normal 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
|
||||
38
transport/backend/app/schemas/vehicle.py
Normal file
38
transport/backend/app/schemas/vehicle.py
Normal 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
|
||||
0
transport/backend/app/services/__init__.py
Normal file
0
transport/backend/app/services/__init__.py
Normal file
83
transport/backend/app/services/connection_checker.py
Normal file
83
transport/backend/app/services/connection_checker.py
Normal 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) # Проверять каждую минуту
|
||||
55
transport/backend/app/services/event_detector.py
Normal file
55
transport/backend/app/services/event_detector.py
Normal 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
|
||||
63
transport/backend/app/services/websocket_manager.py
Normal file
63
transport/backend/app/services/websocket_manager.py
Normal 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()
|
||||
18
transport/backend/app/websocket.py
Normal file
18
transport/backend/app/websocket.py
Normal 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)
|
||||
11
transport/backend/requirements.txt
Normal file
11
transport/backend/requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
alembic==1.12.1
|
||||
asyncpg==0.29.0
|
||||
psycopg2-binary==2.9.9
|
||||
pydantic==2.5.2
|
||||
pydantic-settings==2.1.0
|
||||
python-multipart==0.0.6
|
||||
websockets==12.0
|
||||
httpx==0.25.2
|
||||
0
transport/backend/simulator/__init__.py
Normal file
0
transport/backend/simulator/__init__.py
Normal file
121
transport/backend/simulator/run.py
Normal file
121
transport/backend/simulator/run.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Симулятор движения транспортных средств.
|
||||
Генерирует реалистичные данные о перемещении объектов.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
import math
|
||||
import httpx
|
||||
from datetime import datetime
|
||||
|
||||
# Backend API URL (внутри Docker сети, без /api — root_path только для nginx)
|
||||
API_URL = "http://backend:8000"
|
||||
|
||||
# Начальные координаты (Новосибирск - центр)
|
||||
START_COORDS = [
|
||||
(55.0304, 82.9204), # Центр
|
||||
(55.0411, 82.9344), # Север
|
||||
(55.0198, 82.9064), # Юг
|
||||
(55.0350, 82.8904), # Запад
|
||||
(55.0250, 82.9504), # Восток
|
||||
]
|
||||
|
||||
|
||||
class VehicleSimulator:
|
||||
def __init__(self, vehicle_id: int, start_lat: float, start_lon: float):
|
||||
self.vehicle_id = vehicle_id
|
||||
self.lat = start_lat
|
||||
self.lon = start_lon
|
||||
self.speed = random.uniform(20, 60) # km/h
|
||||
self.heading = random.uniform(0, 360) # degrees
|
||||
self.is_stopped = False
|
||||
self.stop_duration = 0
|
||||
|
||||
def update(self):
|
||||
"""Обновить позицию транспортного средства"""
|
||||
# Случайная остановка
|
||||
if not self.is_stopped and random.random() < 0.02: # 2% шанс остановиться
|
||||
self.is_stopped = True
|
||||
self.stop_duration = random.randint(5, 30) # секунд
|
||||
self.speed = 0
|
||||
|
||||
if self.is_stopped:
|
||||
self.stop_duration -= 1
|
||||
if self.stop_duration <= 0:
|
||||
self.is_stopped = False
|
||||
self.speed = random.uniform(20, 60)
|
||||
|
||||
if not self.is_stopped:
|
||||
# Случайное изменение направления
|
||||
self.heading += random.uniform(-15, 15)
|
||||
self.heading = self.heading % 360
|
||||
|
||||
# Случайное изменение скорости
|
||||
self.speed += random.uniform(-5, 5)
|
||||
self.speed = max(10, min(90, self.speed)) # Ограничение 10-90 км/ч
|
||||
|
||||
# Расчёт нового положения
|
||||
# Примерно: 1 градус широты = 111 км, 1 градус долготы = 111 * cos(lat) км
|
||||
speed_ms = self.speed / 3.6 # м/с
|
||||
distance = speed_ms * 2 # за 2 секунды
|
||||
|
||||
# Перевод в градусы
|
||||
delta_lat = (distance * math.cos(math.radians(self.heading))) / 111000
|
||||
delta_lon = (distance * math.sin(math.radians(self.heading))) / (111000 * math.cos(math.radians(self.lat)))
|
||||
|
||||
self.lat += delta_lat
|
||||
self.lon += delta_lon
|
||||
|
||||
return {
|
||||
"vehicle_id": self.vehicle_id,
|
||||
"lat": round(self.lat, 6),
|
||||
"lon": round(self.lon, 6),
|
||||
"speed": round(self.speed, 1),
|
||||
"heading": round(self.heading, 1),
|
||||
"timestamp": datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
|
||||
async def send_position(client: httpx.AsyncClient, position: dict):
|
||||
"""Отправить позицию на сервер"""
|
||||
try:
|
||||
response = await client.post(f"{API_URL}/ingest/position", json=position)
|
||||
if response.status_code == 201:
|
||||
print(f"✓ Vehicle {position['vehicle_id']}: ({position['lat']}, {position['lon']}) @ {position['speed']} km/h")
|
||||
else:
|
||||
print(f"✗ Vehicle {position['vehicle_id']}: Error {response.status_code}")
|
||||
except Exception as e:
|
||||
print(f"✗ Vehicle {position['vehicle_id']}: {e}")
|
||||
|
||||
|
||||
async def main():
|
||||
print("🚗 Запуск симулятора транспорта...")
|
||||
print(f"📡 API URL: {API_URL}")
|
||||
|
||||
# Создаём симуляторы для каждого транспортного средства
|
||||
simulators = []
|
||||
for i, (lat, lon) in enumerate(START_COORDS, start=1):
|
||||
sim = VehicleSimulator(vehicle_id=i, start_lat=lat, start_lon=lon)
|
||||
simulators.append(sim)
|
||||
print(f" → Vehicle {i}: начальная позиция ({lat}, {lon})")
|
||||
|
||||
print("\n🔄 Начинаем отправку данных (Ctrl+C для остановки)...\n")
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
while True:
|
||||
# Обновляем и отправляем позиции всех транспортных средств
|
||||
tasks = []
|
||||
for sim in simulators:
|
||||
position = sim.update()
|
||||
tasks.append(send_position(client, position))
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
await asyncio.sleep(2) # Интервал 2 секунды
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⏹ Симулятор остановлен")
|
||||
Reference in New Issue
Block a user