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