init
This commit is contained in:
commit
3a308a9c53
39 changed files with 1682 additions and 0 deletions
17
.env.example
Normal file
17
.env.example
Normal 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
1
.gititnore
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
.env
|
||||||
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
3.12
|
||||||
39
backend/alembic.ini
Normal file
39
backend/alembic.ini
Normal 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
|
||||||
0
backend/alembic/__init__.py
Normal file
0
backend/alembic/__init__.py
Normal file
55
backend/alembic/env.py
Normal file
55
backend/alembic/env.py
Normal 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()
|
||||||
26
backend/alembic/script.py.mako
Normal file
26
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"}
|
||||||
132
backend/alembic/versions/0001_initial_schema.py
Normal file
132
backend/alembic/versions/0001_initial_schema.py
Normal 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
0
backend/app/__init__.py
Normal file
19
backend/app/config.py
Normal file
19
backend/app/config.py
Normal 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()
|
||||||
30
backend/app/dependencies.py
Normal file
30
backend/app/dependencies.py
Normal 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
44
backend/app/main.py
Normal 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()
|
||||||
0
backend/app/middleware/__init__.py
Normal file
0
backend/app/middleware/__init__.py
Normal file
26
backend/app/middleware/auth.py
Normal file
26
backend/app/middleware/auth.py
Normal 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)
|
||||||
16
backend/app/models/__init__.py
Normal file
16
backend/app/models/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
22
backend/app/models/base.py
Normal file
22
backend/app/models/base.py
Normal 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,
|
||||||
|
)
|
||||||
63
backend/app/models/device.py
Normal file
63
backend/app/models/device.py
Normal 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")
|
||||||
49
backend/app/models/object.py
Normal file
49
backend/app/models/object.py
Normal 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
14
backend/app/models/tag.py
Normal 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)
|
||||||
40
backend/app/models/user.py
Normal file
40
backend/app/models/user.py
Normal 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")
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
44
backend/app/routers/auth.py
Normal file
44
backend/app/routers/auth.py
Normal 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)
|
||||||
237
backend/app/routers/devices.py
Normal file
237
backend/app/routers/devices.py
Normal 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)
|
||||||
138
backend/app/routers/objects.py
Normal file
138
backend/app/routers/objects.py
Normal 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,
|
||||||
|
)
|
||||||
61
backend/app/routers/tags.py
Normal file
61
backend/app/routers/tags.py
Normal 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)
|
||||||
0
backend/app/schemas/__init__.py
Normal file
0
backend/app/schemas/__init__.py
Normal file
20
backend/app/schemas/auth.py
Normal file
20
backend/app/schemas/auth.py
Normal 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
|
||||||
105
backend/app/schemas/device.py
Normal file
105
backend/app/schemas/device.py
Normal 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] = []
|
||||||
81
backend/app/schemas/object.py
Normal file
81
backend/app/schemas/object.py
Normal 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] = []
|
||||||
26
backend/app/schemas/tag.py
Normal file
26
backend/app/schemas/tag.py
Normal 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
|
||||||
32
backend/app/schemas/user.py
Normal file
32
backend/app/schemas/user.py
Normal 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
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
95
backend/app/services/auth.py
Normal file
95
backend/app/services/auth.py
Normal 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()
|
||||||
23
backend/app/services/crypto.py
Normal file
23
backend/app/services/crypto.py
Normal 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()
|
||||||
16
backend/app/services/database.py
Normal file
16
backend/app/services/database.py
Normal 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,
|
||||||
|
)
|
||||||
120
backend/app/services/object_db.py
Normal file
120
backend/app/services/object_db.py
Normal 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
39
backend/pyproject.toml
Normal 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
14
docker/Dockerfile.backend
Normal 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
37
docker/docker-compose.yml
Normal 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:
|
||||||
Loading…
Add table
Reference in a new issue