This commit is contained in:
dv 2026-04-07 00:00:50 +03:00
commit 3a308a9c53
39 changed files with 1682 additions and 0 deletions

17
.env.example Normal file
View file

@ -0,0 +1,17 @@
# ── Database ──────────────────────────────────────────────────────────────────
DATABASE_URL=postgresql+asyncpg://fleet:fleet@localhost:5432/fleet
# ── Security ──────────────────────────────────────────────────────────────────
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change-me-generate-a-real-secret-key
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
ENCRYPTION_KEY=change-me-generate-fernet-key
# JWT token lifetime (seconds)
ACCESS_TOKEN_EXPIRE_SECONDS=900
REFRESH_TOKEN_EXPIRE_DAYS=7
# ── App ───────────────────────────────────────────────────────────────────────
APP_ENV=development
DEBUG=true

1
.gititnore Normal file
View file

@ -0,0 +1 @@
.env

1
backend/.python-version Normal file
View file

@ -0,0 +1 @@
3.12

39
backend/alembic.ini Normal file
View file

@ -0,0 +1,39 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://fleet:fleet@localhost:5432/fleet
[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

View file

55
backend/alembic/env.py Normal file
View file

@ -0,0 +1,55 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy import pool
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
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 do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = config.get_main_option("sqlalchemy.url")
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View 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"}

View file

@ -0,0 +1,132 @@
"""Initial schema
Revision ID: 0001
Revises:
Create Date: 2026-04-06
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from alembic import op
revision: str = "0001"
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:
op.create_table(
"users",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("username", sa.String(100), unique=True, nullable=False),
sa.Column("email", sa.String(255), unique=True, nullable=True),
sa.Column("full_name", sa.String(255), nullable=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("role", sa.String(20), nullable=False, server_default="operator"),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_users_username", "users", ["username"])
op.create_table(
"user_sessions",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("refresh_token", sa.String(512), unique=True, nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("user_agent", sa.String(500), nullable=True),
sa.Column("ip_address", sa.String(45), nullable=True),
)
op.create_index("ix_user_sessions_user_id", "user_sessions", ["user_id"])
op.create_index("ix_user_sessions_refresh_token", "user_sessions", ["refresh_token"])
op.create_table(
"tags",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(100), unique=True, nullable=False),
sa.Column("color", sa.String(7), nullable=False, server_default="#1677ff"),
sa.Column("tag_type", sa.String(50), nullable=False, server_default="general"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"objects",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.String(1000), nullable=True),
sa.Column("server_ip", sa.String(45), nullable=True),
# Object DB connection
sa.Column("db_host", sa.String(255), nullable=True),
sa.Column("db_port", sa.Integer, nullable=False, server_default="5432"),
sa.Column("db_name", sa.String(100), nullable=True),
sa.Column("db_user", sa.String(100), nullable=True),
sa.Column("db_pass_enc", sa.String(500), nullable=True),
sa.Column("db_table", sa.String(100), nullable=True),
sa.Column("db_col_ip", sa.String(100), nullable=True),
sa.Column("db_col_type", sa.String(100), nullable=True),
sa.Column("db_via_tunnel", sa.Boolean, nullable=False, server_default="false"),
# Default SSH
sa.Column("ssh_user", sa.String(100), nullable=True),
sa.Column("ssh_pass_enc", sa.String(500), nullable=True),
sa.Column("ssh_port", sa.Integer, nullable=False, server_default="22"),
sa.Column("notes", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_table(
"object_tags",
sa.Column("object_id", sa.Integer, sa.ForeignKey("objects.id", ondelete="CASCADE"), primary_key=True),
sa.Column("tag_id", sa.Integer, sa.ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
op.create_table(
"devices",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("object_id", sa.Integer, sa.ForeignKey("objects.id", ondelete="RESTRICT"), nullable=False),
sa.Column("ip", sa.String(45), nullable=False),
sa.Column("hostname", sa.String(255), nullable=True),
sa.Column("mac", sa.String(17), nullable=True),
sa.Column("category", sa.String(20), nullable=False, server_default="other"),
sa.Column("role", sa.String(20), nullable=False, server_default="other"),
sa.Column("model", sa.String(255), nullable=True),
sa.Column("firmware_version", sa.String(100), nullable=True),
sa.Column("source", sa.String(20), nullable=False, server_default="manual"),
sa.Column("external_id", sa.String(255), nullable=True),
# SSH overrides
sa.Column("ssh_user_override", sa.String(100), nullable=True),
sa.Column("ssh_pass_override_enc", sa.String(500), nullable=True),
sa.Column("ssh_port_override", sa.Integer, nullable=True),
# Extra
sa.Column("device_meta", JSONB, nullable=False, server_default="{}"),
sa.Column("capabilities", ARRAY(sa.String), nullable=False, server_default="{}"),
# Monitoring
sa.Column("monitor_enabled", sa.Boolean, nullable=False, server_default="false"),
sa.Column("last_seen", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_ping_ms", sa.Integer, nullable=True),
sa.Column("status", sa.String(20), nullable=False, server_default="unknown"),
sa.Column("notes", sa.Text, nullable=True),
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_devices_object_id", "devices", ["object_id"])
op.create_index("ix_devices_category", "devices", ["category"])
op.create_index("ix_devices_status", "devices", ["status"])
op.create_unique_constraint("uq_device_object_ip", "devices", ["object_id", "ip"])
def downgrade() -> None:
op.drop_table("devices")
op.drop_table("object_tags")
op.drop_table("objects")
op.drop_table("tags")
op.drop_table("user_sessions")
op.drop_table("users")

0
backend/app/__init__.py Normal file
View file

19
backend/app/config.py Normal file
View file

@ -0,0 +1,19 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
DATABASE_URL: str = "postgresql+asyncpg://fleet:fleet@localhost:5432/fleet"
SECRET_KEY: str = "change-me"
ENCRYPTION_KEY: str = "change-me"
ACCESS_TOKEN_EXPIRE_SECONDS: int = 900
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
APP_ENV: str = "development"
DEBUG: bool = False
settings = Settings()

View file

@ -0,0 +1,30 @@
from typing import AsyncGenerator
from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.services.auth import get_user_by_id
from app.services.database import AsyncSessionLocal
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
async with session.begin():
yield session
async def get_current_user(request: Request, db: AsyncSession = Depends(get_db)) -> User:
user_id = getattr(request.state, "user_id", None)
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
user = await get_user_by_id(db, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
return user
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return current_user

44
backend/app/main.py Normal file
View file

@ -0,0 +1,44 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.middleware.auth import AuthMiddleware
from app.routers import auth, devices, objects, tags
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: future workers (monitor, stale task recovery) will be launched here in Этап 2+
yield
# Shutdown
def create_app() -> FastAPI:
app = FastAPI(
title="Fleet Manager",
description="Centralized device fleet management service",
version="0.1.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(AuthMiddleware)
app.include_router(auth.router)
app.include_router(objects.router)
app.include_router(devices.router)
app.include_router(tags.router)
return app
app = create_app()

View file

View file

@ -0,0 +1,26 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.services.auth import decode_access_token
_PUBLIC_PATHS = {"/api/v1/auth/login", "/api/v1/auth/refresh", "/docs", "/openapi.json", "/redoc"}
class AuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
if path in _PUBLIC_PATHS or path.startswith("/docs") or path.startswith("/redoc"):
return await call_next(request)
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
user_id = decode_access_token(token)
if user_id is not None:
request.state.user_id = user_id
else:
request.state.user_id = None
else:
request.state.user_id = None
return await call_next(request)

View file

@ -0,0 +1,16 @@
from .base import Base, TimestampMixin
from .device import Device
from .object import Object, object_tags
from .tag import Tag
from .user import User, UserSession
__all__ = [
"Base",
"TimestampMixin",
"User",
"UserSession",
"Tag",
"Object",
"object_tags",
"Device",
]

View file

@ -0,0 +1,22 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)

View file

@ -0,0 +1,63 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin
DEVICE_CATEGORIES = ("raspberry", "mikrotik", "camera", "embedded", "server", "other")
DEVICE_ROLES = ("entry", "exit", "server", "nvr", "switch", "other")
DEVICE_STATUSES = ("online", "offline", "unknown")
DEVICE_SOURCES = ("auto_sync", "manual", "csv_import", "sheets_sync")
class Device(Base, TimestampMixin):
__tablename__ = "devices"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
object_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("objects.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
ip: Mapped[str] = mapped_column(String(45), nullable=False)
hostname: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
mac: Mapped[Optional[str]] = mapped_column(String(17), nullable=True)
# Classification
category: Mapped[str] = mapped_column(String(20), nullable=False, default="other")
role: Mapped[str] = mapped_column(String(20), nullable=False, default="other")
model: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
firmware_version: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
# Source tracking
source: Mapped[str] = mapped_column(String(20), nullable=False, default="manual")
external_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
# SSH overrides (NULL = use object defaults)
ssh_user_override: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
ssh_pass_override_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ssh_port_override: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Device-specific metadata (Mikrotik API port, RTSP URL, etc.)
device_meta: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# Capabilities populated by drivers
capabilities: Mapped[list[str]] = mapped_column(
ARRAY(String), nullable=False, default=list
)
# Monitoring
monitor_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
last_seen: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_ping_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="unknown")
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
object: Mapped["Object"] = relationship("Object", back_populates="devices")

View file

@ -0,0 +1,49 @@
from typing import Optional
from sqlalchemy import Boolean, ForeignKey, Integer, String, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin
# Association table for object ↔ tags
object_tags = Table(
"object_tags",
Base.metadata,
Column("object_id", Integer, ForeignKey("objects.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
)
class Object(Base, TimestampMixin):
__tablename__ = "objects"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
# Network
server_ip: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
# Object DB connection (for auto-sync from site's PostgreSQL)
db_host: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
db_port: Mapped[int] = mapped_column(Integer, nullable=False, default=5432)
db_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
db_user: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
db_pass_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
db_table: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
db_col_ip: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
db_col_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
db_via_tunnel: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Default SSH credentials for devices on this object
ssh_user: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
ssh_pass_enc: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ssh_port: Mapped[int] = mapped_column(Integer, nullable=False, default=22)
notes: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
devices: Mapped[list["Device"]] = relationship(
"Device", back_populates="object", cascade="all, delete-orphan"
)
tags: Mapped[list["Tag"]] = relationship("Tag", secondary=object_tags)

14
backend/app/models/tag.py Normal file
View file

@ -0,0 +1,14 @@
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class Tag(Base):
__tablename__ = "tags"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
color: Mapped[str] = mapped_column(String(7), nullable=False, default="#1677ff")
tag_type: Mapped[str] = mapped_column(String(50), nullable=False, default="general")
created_at: Mapped[object] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)

View file

@ -0,0 +1,40 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base, TimestampMixin
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True)
email: Mapped[Optional[str]] = mapped_column(String(255), unique=True, nullable=True)
full_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default="operator")
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
sessions: Mapped[list["UserSession"]] = relationship(
"UserSession", back_populates="user", cascade="all, delete-orphan"
)
class UserSession(Base):
__tablename__ = "user_sessions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
refresh_token: Mapped[str] = mapped_column(String(512), unique=True, nullable=False, index=True)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
user_agent: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
user: Mapped["User"] = relationship("User", back_populates="sessions")

View file

View file

@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db
from app.schemas.auth import LoginRequest, LogoutRequest, RefreshRequest, TokenResponse
from app.services import auth as auth_service
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
user = await auth_service.authenticate_user(db, body.username, body.password)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
access_token = auth_service.create_access_token(user.id)
refresh_token = await auth_service.create_session(
db,
user.id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("User-Agent"),
)
return TokenResponse(access_token=access_token, refresh_token=refresh_token)
@router.post("/refresh", response_model=TokenResponse)
async def refresh(body: RefreshRequest, request: Request, db: AsyncSession = Depends(get_db)):
result = await auth_service.refresh_session(
db,
body.refresh_token,
ip_address=request.client.host if request.client else None,
)
if result is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired refresh token")
user, new_refresh_token = result
access_token = auth_service.create_access_token(user.id)
return TokenResponse(access_token=access_token, refresh_token=new_refresh_token)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(body: LogoutRequest, db: AsyncSession = Depends(get_db)):
await auth_service.revoke_session(db, body.refresh_token)

View file

@ -0,0 +1,237 @@
import csv
import io
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.device import DEVICE_CATEGORIES, DEVICE_ROLES, Device
from app.models.object import Object
from app.models.user import User
from app.schemas.device import (
DeviceCreate,
DeviceImportPreview,
DeviceImportResult,
DeviceImportRow,
DeviceRead,
DeviceUpdate,
)
from app.services import crypto
router = APIRouter(prefix="/api/v1/objects/{obj_id}/devices", tags=["devices"])
async def _get_object(db: AsyncSession, obj_id: int) -> Object:
result = await db.execute(select(Object).where(Object.id == obj_id))
obj = result.scalar_one_or_none()
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
return obj
async def _get_device(db: AsyncSession, obj_id: int, device_id: int) -> Device:
result = await db.execute(
select(Device).where(Device.id == device_id, Device.object_id == obj_id)
)
device = result.scalar_one_or_none()
if device is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found")
return device
@router.get("", response_model=list[DeviceRead])
async def list_devices(
obj_id: int,
category: str | None = None,
role: str | None = None,
status: str | None = Query(None),
is_active: bool | None = None,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
query = select(Device).where(Device.object_id == obj_id)
if category:
query = query.where(Device.category == category)
if role:
query = query.where(Device.role == role)
if status:
query = query.where(Device.status == status)
if is_active is not None:
query = query.where(Device.is_active == is_active)
query = query.order_by(Device.ip)
result = await db.execute(query)
return result.scalars().all()
@router.post("", response_model=DeviceRead, status_code=201)
async def create_device(
obj_id: int,
body: DeviceCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
data = body.model_dump(exclude={"ssh_password_override"})
device = Device(object_id=obj_id, **data)
if body.ssh_password_override:
device.ssh_pass_override_enc = crypto.encrypt(body.ssh_password_override)
db.add(device)
await db.flush()
await db.refresh(device)
return device
@router.get("/import/preview", response_model=DeviceImportPreview)
async def preview_csv_import(
obj_id: int,
file: Annotated[UploadFile, File()],
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
return await _parse_csv_import(db, obj_id, file, dry_run=True)
@router.post("/import", response_model=DeviceImportResult, status_code=201)
async def import_csv(
obj_id: int,
file: Annotated[UploadFile, File()],
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
await _get_object(db, obj_id)
preview = await _parse_csv_import(db, obj_id, file, dry_run=False)
return DeviceImportResult(
created=len(preview.would_create),
updated=len(preview.would_update),
errors=preview.errors,
)
async def _parse_csv_import(
db: AsyncSession, obj_id: int, file: UploadFile, dry_run: bool
) -> DeviceImportPreview:
content = await file.read()
text = content.decode("utf-8-sig") # handle BOM
reader = csv.DictReader(io.StringIO(text))
would_create: list[DeviceImportRow] = []
would_update: list[dict] = []
errors: list[dict] = []
for row_num, row in enumerate(reader, start=2):
ip = (row.get("ip") or "").strip()
if not ip:
errors.append({"row": row_num, "error": "IP address is required"})
continue
hostname = (row.get("hostname") or "").strip() or None
category = (row.get("category") or "other").strip().lower()
role = (row.get("role") or "other").strip().lower()
model = (row.get("model") or "").strip() or None
mac = (row.get("mac") or "").strip() or None
notes = (row.get("notes") or "").strip() or None
if category not in DEVICE_CATEGORIES:
category = "other"
if role not in DEVICE_ROLES:
role = "other"
existing_result = await db.execute(
select(Device).where(Device.object_id == obj_id, Device.ip == ip)
)
existing = existing_result.scalar_one_or_none()
if existing is not None:
changes: dict = {}
if hostname and existing.hostname != hostname:
changes["hostname"] = {"from": existing.hostname, "to": hostname}
if existing.category != category:
changes["category"] = {"from": existing.category, "to": category}
if model and existing.model != model:
changes["model"] = {"from": existing.model, "to": model}
would_update.append({"ip": ip, "id": existing.id, "changes": changes})
if not dry_run and changes:
if "hostname" in changes:
existing.hostname = hostname
if "category" in changes:
existing.category = category
if "model" in changes:
existing.model = model
existing.source = "csv_import"
else:
row_data = DeviceImportRow(
ip=ip, hostname=hostname, category=category, role=role, model=model, mac=mac, notes=notes
)
would_create.append(row_data)
if not dry_run:
db.add(Device(
object_id=obj_id,
ip=ip,
hostname=hostname,
category=category,
role=role,
model=model,
mac=mac,
notes=notes,
source="csv_import",
))
if not dry_run:
await db.flush()
return DeviceImportPreview(would_create=would_create, would_update=would_update, errors=errors)
@router.get("/{device_id}", response_model=DeviceRead)
async def get_device(
obj_id: int,
device_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
return await _get_device(db, obj_id, device_id)
@router.patch("/{device_id}", response_model=DeviceRead)
async def update_device(
obj_id: int,
device_id: int,
body: DeviceUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
device = await _get_device(db, obj_id, device_id)
data = body.model_dump(exclude_none=True, exclude={"ssh_password_override"})
for field, value in data.items():
setattr(device, field, value)
if body.ssh_password_override is not None:
device.ssh_pass_override_enc = (
crypto.encrypt(body.ssh_password_override) if body.ssh_password_override else None
)
await db.flush()
await db.refresh(device)
return device
@router.delete("/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_device(
obj_id: int,
device_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
device = await _get_device(db, obj_id, device_id)
await db.delete(device)

View file

@ -0,0 +1,138 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.dependencies import get_current_user, get_db
from app.models.device import Device
from app.models.object import Object
from app.models.tag import Tag
from app.models.user import User
from app.schemas.object import ObjectCreate, ObjectRead, ObjectUpdate, SyncResult
from app.services import crypto
from app.services.object_db import sync_from_object_db
router = APIRouter(prefix="/api/v1/objects", tags=["objects"])
def _apply_credentials(obj: Object, db_password: str | None, ssh_password: str | None) -> None:
if db_password is not None:
obj.db_pass_enc = crypto.encrypt(db_password)
if ssh_password is not None:
obj.ssh_pass_enc = crypto.encrypt(ssh_password)
async def _load_object(db: AsyncSession, obj_id: int) -> Object:
result = await db.execute(
select(Object).options(selectinload(Object.tags)).where(Object.id == obj_id)
)
obj = result.scalar_one_or_none()
if obj is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Object not found")
return obj
@router.get("", response_model=list[ObjectRead])
async def list_objects(
is_active: bool | None = None,
tag_id: int | None = None,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
query = select(Object).options(selectinload(Object.tags))
if is_active is not None:
query = query.where(Object.is_active == is_active)
if tag_id is not None:
query = query.where(Object.tags.any(Tag.id == tag_id))
query = query.order_by(Object.name)
result = await db.execute(query)
return result.scalars().all()
@router.post("", response_model=ObjectRead, status_code=status.HTTP_201_CREATED)
async def create_object(
body: ObjectCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
data = body.model_dump(exclude={"db_password", "ssh_password", "tag_ids"})
obj = Object(**data)
_apply_credentials(obj, body.db_password, body.ssh_password)
if body.tag_ids:
tags_result = await db.execute(select(Tag).where(Tag.id.in_(body.tag_ids)))
obj.tags = list(tags_result.scalars().all())
db.add(obj)
await db.flush()
await db.refresh(obj, ["tags"])
return obj
@router.get("/{obj_id}", response_model=ObjectRead)
async def get_object(
obj_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
return await _load_object(db, obj_id)
@router.patch("/{obj_id}", response_model=ObjectRead)
async def update_object(
obj_id: int,
body: ObjectUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
obj = await _load_object(db, obj_id)
data = body.model_dump(exclude_none=True, exclude={"db_password", "ssh_password", "tag_ids"})
for field, value in data.items():
setattr(obj, field, value)
_apply_credentials(obj, body.db_password, body.ssh_password)
if body.tag_ids is not None:
tags_result = await db.execute(select(Tag).where(Tag.id.in_(body.tag_ids)))
obj.tags = list(tags_result.scalars().all())
await db.flush()
await db.refresh(obj, ["tags"])
return obj
@router.delete("/{obj_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_object(
obj_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
obj = await _load_object(db, obj_id)
# Check for devices (RESTRICT constraint would catch this, but give a clear message)
count_result = await db.execute(
select(Device.id).where(Device.object_id == obj_id).limit(1)
)
if count_result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cannot delete object with existing devices. Remove all devices first.",
)
await db.delete(obj)
@router.post("/{obj_id}/sync", response_model=SyncResult)
async def sync_devices(
obj_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
obj = await _load_object(db, obj_id)
result = await sync_from_object_db(db, obj)
return SyncResult(
created=result.created,
updated=result.updated,
skipped=result.skipped,
errors=result.errors,
)

View file

@ -0,0 +1,61 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_current_user, get_db
from app.models.tag import Tag
from app.models.user import User
from app.schemas.tag import TagCreate, TagRead, TagUpdate
router = APIRouter(prefix="/api/v1/tags", tags=["tags"])
@router.get("", response_model=list[TagRead])
async def list_tags(db: AsyncSession = Depends(get_db), _: User = Depends(get_current_user)):
result = await db.execute(select(Tag).order_by(Tag.name))
return result.scalars().all()
@router.post("", response_model=TagRead, status_code=status.HTTP_201_CREATED)
async def create_tag(
body: TagCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
tag = Tag(**body.model_dump())
db.add(tag)
await db.flush()
await db.refresh(tag)
return tag
@router.patch("/{tag_id}", response_model=TagRead)
async def update_tag(
tag_id: int,
body: TagUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found")
for field, value in body.model_dump(exclude_none=True).items():
setattr(tag, field, value)
await db.flush()
await db.refresh(tag)
return tag
@router.delete("/{tag_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_tag(
tag_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
result = await db.execute(select(Tag).where(Tag.id == tag_id))
tag = result.scalar_one_or_none()
if tag is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tag not found")
await db.delete(tag)

View file

View file

@ -0,0 +1,20 @@
from pydantic import BaseModel
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str
class LogoutRequest(BaseModel):
refresh_token: str

View file

@ -0,0 +1,105 @@
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, field_validator
from app.models.device import DEVICE_CATEGORIES, DEVICE_ROLES, DEVICE_SOURCES
class DeviceCreate(BaseModel):
ip: str
hostname: Optional[str] = None
mac: Optional[str] = None
category: str = "other"
role: str = "other"
model: Optional[str] = None
firmware_version: Optional[str] = None
source: str = "manual"
ssh_user_override: Optional[str] = None
ssh_password_override: Optional[str] = None # plaintext, encrypted on write
ssh_port_override: Optional[int] = None
device_meta: dict[str, Any] = {}
monitor_enabled: bool = False
notes: Optional[str] = None
is_active: bool = True
@field_validator("category")
@classmethod
def validate_category(cls, v: str) -> str:
if v not in DEVICE_CATEGORIES:
raise ValueError(f"category must be one of {DEVICE_CATEGORIES}")
return v
@field_validator("role")
@classmethod
def validate_role(cls, v: str) -> str:
if v not in DEVICE_ROLES:
raise ValueError(f"role must be one of {DEVICE_ROLES}")
return v
class DeviceUpdate(BaseModel):
ip: Optional[str] = None
hostname: Optional[str] = None
mac: Optional[str] = None
category: Optional[str] = None
role: Optional[str] = None
model: Optional[str] = None
firmware_version: Optional[str] = None
ssh_user_override: Optional[str] = None
ssh_password_override: Optional[str] = None
ssh_port_override: Optional[int] = None
device_meta: Optional[dict[str, Any]] = None
monitor_enabled: Optional[bool] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
class DeviceRead(BaseModel):
id: int
object_id: int
ip: str
hostname: Optional[str] = None
mac: Optional[str] = None
category: str
role: str
model: Optional[str] = None
firmware_version: Optional[str] = None
source: str
external_id: Optional[str] = None
ssh_user_override: Optional[str] = None
ssh_port_override: Optional[int] = None
device_meta: dict[str, Any] = {}
capabilities: list[str] = []
monitor_enabled: bool
last_seen: Optional[datetime] = None
last_ping_ms: Optional[int] = None
status: str
notes: Optional[str] = None
is_active: bool
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class DeviceImportRow(BaseModel):
ip: str
hostname: Optional[str] = None
category: str = "other"
role: str = "other"
model: Optional[str] = None
mac: Optional[str] = None
notes: Optional[str] = None
class DeviceImportPreview(BaseModel):
would_create: list[DeviceImportRow] = []
would_update: list[dict] = []
errors: list[dict] = []
class DeviceImportResult(BaseModel):
created: int
updated: int
errors: list[dict] = []

View file

@ -0,0 +1,81 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
from app.schemas.tag import TagRead
class ObjectCreate(BaseModel):
name: str
description: Optional[str] = None
server_ip: Optional[str] = None
# Object DB sync settings
db_host: Optional[str] = None
db_port: int = 5432
db_name: Optional[str] = None
db_user: Optional[str] = None
db_password: Optional[str] = None # plaintext, encrypted on write
db_table: Optional[str] = None
db_col_ip: Optional[str] = None
db_col_type: Optional[str] = None
db_via_tunnel: bool = False
# SSH defaults
ssh_user: Optional[str] = None
ssh_password: Optional[str] = None # plaintext, encrypted on write
ssh_port: int = 22
notes: Optional[str] = None
is_active: bool = True
tag_ids: list[int] = []
class ObjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
server_ip: Optional[str] = None
db_host: Optional[str] = None
db_port: Optional[int] = None
db_name: Optional[str] = None
db_user: Optional[str] = None
db_password: Optional[str] = None
db_table: Optional[str] = None
db_col_ip: Optional[str] = None
db_col_type: Optional[str] = None
db_via_tunnel: Optional[bool] = None
ssh_user: Optional[str] = None
ssh_password: Optional[str] = None
ssh_port: Optional[int] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
tag_ids: Optional[list[int]] = None
class ObjectRead(BaseModel):
id: int
name: str
description: Optional[str] = None
server_ip: Optional[str] = None
db_host: Optional[str] = None
db_port: int
db_name: Optional[str] = None
db_user: Optional[str] = None
db_table: Optional[str] = None
db_col_ip: Optional[str] = None
db_col_type: Optional[str] = None
db_via_tunnel: bool
ssh_user: Optional[str] = None
ssh_port: int
notes: Optional[str] = None
is_active: bool
created_at: datetime
updated_at: datetime
tags: list[TagRead] = []
model_config = {"from_attributes": True}
class SyncResult(BaseModel):
created: int
updated: int
skipped: int
errors: list[str] = []

View file

@ -0,0 +1,26 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel
class TagRead(BaseModel):
id: int
name: str
color: str
tag_type: str
created_at: datetime
model_config = {"from_attributes": True}
class TagCreate(BaseModel):
name: str
color: str = "#1677ff"
tag_type: str = "general"
class TagUpdate(BaseModel):
name: Optional[str] = None
color: Optional[str] = None
tag_type: Optional[str] = None

View file

@ -0,0 +1,32 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, EmailStr
class UserRead(BaseModel):
id: int
username: str
email: Optional[str] = None
full_name: Optional[str] = None
role: str
is_active: bool
created_at: datetime
model_config = {"from_attributes": True}
class UserCreate(BaseModel):
username: str
password: str
email: Optional[str] = None
full_name: Optional[str] = None
role: str = "operator"
class UserUpdate(BaseModel):
email: Optional[str] = None
full_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None

View file

View file

@ -0,0 +1,95 @@
import secrets
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.user import User, UserSession
ALGORITHM = "HS256"
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(seconds=settings.ACCESS_TOKEN_EXPIRE_SECONDS)
return jwt.encode({"sub": str(user_id), "exp": expire}, settings.SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> int | None:
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
sub = payload.get("sub")
if sub is None:
return None
return int(sub)
except (JWTError, ValueError):
return None
async def authenticate_user(db: AsyncSession, username: str, password: str) -> User | None:
result = await db.execute(select(User).where(User.username == username, User.is_active == True))
user = result.scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
return None
return user
async def create_session(
db: AsyncSession, user_id: int, ip_address: str | None = None, user_agent: str | None = None
) -> str:
token = secrets.token_urlsafe(32)
now = datetime.now(timezone.utc)
session = UserSession(
user_id=user_id,
refresh_token=token,
expires_at=now + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
created_at=now,
ip_address=ip_address,
user_agent=user_agent,
)
db.add(session)
await db.flush()
return token
async def refresh_session(
db: AsyncSession, refresh_token: str, ip_address: str | None = None
) -> tuple[User, str] | None:
now = datetime.now(timezone.utc)
result = await db.execute(
select(UserSession)
.where(UserSession.refresh_token == refresh_token, UserSession.expires_at > now)
)
session = result.scalar_one_or_none()
if session is None:
return None
user_result = await db.execute(select(User).where(User.id == session.user_id, User.is_active == True))
user = user_result.scalar_one_or_none()
if user is None:
return None
# Rotate: delete old session, create new
await db.delete(session)
new_token = await create_session(db, user.id, ip_address=ip_address)
return user, new_token
async def revoke_session(db: AsyncSession, refresh_token: str) -> None:
await db.execute(delete(UserSession).where(UserSession.refresh_token == refresh_token))
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
result = await db.execute(select(User).where(User.id == user_id, User.is_active == True))
return result.scalar_one_or_none()

View file

@ -0,0 +1,23 @@
from cryptography.fernet import Fernet
from app.config import settings
_fernet: Fernet | None = None
def _get_fernet() -> Fernet:
global _fernet
if _fernet is None:
key = settings.ENCRYPTION_KEY
if isinstance(key, str):
key = key.encode()
_fernet = Fernet(key)
return _fernet
def encrypt(plaintext: str) -> str:
return _get_fernet().encrypt(plaintext.encode()).decode()
def decrypt(ciphertext: str) -> str:
return _get_fernet().decrypt(ciphertext.encode()).decode()

View file

@ -0,0 +1,16 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)

View file

@ -0,0 +1,120 @@
"""
Connects to a site object's external PostgreSQL database and syncs devices.
Uses short-lived psycopg3 connections (connect query disconnect).
SSH tunnel support is deferred to Этап 2 (asyncssh not yet available).
"""
from dataclasses import dataclass, field
import psycopg
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.device import Device
from app.models.object import Object
from app.services.crypto import decrypt
@dataclass
class SyncResult:
created: int = 0
updated: int = 0
skipped: int = 0
errors: list[str] = field(default_factory=list)
# Maps type strings from object DB to our device categories
_CATEGORY_MAP: dict[str, str] = {
"raspberry": "raspberry",
"rpi": "raspberry",
"raspberrypi": "raspberry",
"raspberry_pi": "raspberry",
"mikrotik": "mikrotik",
"routeros": "mikrotik",
"camera": "camera",
"cam": "camera",
"ipcam": "camera",
"embedded": "embedded",
"server": "server",
}
def _map_category(raw: str) -> str:
if raw is None:
return "other"
return _CATEGORY_MAP.get(raw.strip().lower(), "other")
async def sync_from_object_db(db: AsyncSession, obj: Object) -> SyncResult:
result = SyncResult()
if not all([obj.db_host, obj.db_name, obj.db_user, obj.db_pass_enc, obj.db_table, obj.db_col_ip]):
result.errors.append("Object DB connection is not fully configured")
return result
if obj.db_via_tunnel:
result.errors.append("SSH tunnel sync is not yet supported (coming in Этап 2)")
return result
try:
password = decrypt(obj.db_pass_enc)
except Exception:
result.errors.append("Failed to decrypt object DB password")
return result
dsn = (
f"host={obj.db_host} port={obj.db_port} dbname={obj.db_name} "
f"user={obj.db_user} password={password} connect_timeout=10"
)
try:
async with await psycopg.AsyncConnection.connect(dsn) as conn:
cols = [obj.db_col_ip]
if obj.db_col_type:
cols.append(obj.db_col_type)
col_list = ", ".join(f'"{c}"' for c in cols)
rows = await conn.execute(f'SELECT {col_list} FROM "{obj.db_table}"')
remote_rows = await rows.fetchall()
except Exception as exc:
result.errors.append(f"Cannot connect to object DB: {exc}")
return result
for row in remote_rows:
ip = str(row[0]).strip() if row[0] else None
raw_type = str(row[1]).strip() if len(row) > 1 and row[1] else None
if not ip:
result.skipped += 1
continue
category = _map_category(raw_type)
# Look for existing device by object + external source
existing = await db.execute(
select(Device).where(
Device.object_id == obj.id,
Device.source == "auto_sync",
Device.ip == ip,
)
)
device = existing.scalar_one_or_none()
if device is not None:
changed = False
if device.category != category:
device.category = category
changed = True
result.updated += 1 if changed else 0
result.skipped += 0 if changed else 1
else:
db.add(Device(
object_id=obj.id,
ip=ip,
category=category,
source="auto_sync",
external_id=ip,
))
result.created += 1
return result

39
backend/pyproject.toml Normal file
View file

@ -0,0 +1,39 @@
[project]
name = "fleet-manager"
version = "0.1.0"
description = "Centralized fleet device management service"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115",
"uvicorn[standard]>=0.32",
"sqlalchemy[asyncio]>=2.0",
"asyncpg>=0.30",
"alembic>=1.14",
"pydantic>=2.10",
"pydantic-settings>=2.7",
"python-jose[cryptography]>=3.3",
"passlib[bcrypt]>=1.7",
"cryptography>=43",
"psycopg[async]>=3.2",
"python-multipart>=0.0.20",
"httpx>=0.28",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3",
"pytest-asyncio>=0.24",
"httpx>=0.28",
"pytest-cov>=6.0",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app"]

14
docker/Dockerfile.backend Normal file
View file

@ -0,0 +1,14 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
iputils-ping \
&& rm -rf /var/lib/apt/lists/*
COPY pyproject.toml .
RUN pip install --no-cache-dir -e .
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

37
docker/docker-compose.yml Normal file
View file

@ -0,0 +1,37 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: fleet
POSTGRES_PASSWORD: fleet
POSTGRES_DB: fleet
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fleet"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ../backend
dockerfile: ../docker/Dockerfile.backend
restart: unless-stopped
env_file: ../.env
environment:
DATABASE_URL: postgresql+asyncpg://fleet:fleet@db:5432/fleet
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
volumes:
- ../backend:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
postgres_data: