commit 3a308a9c53c35a5dfac1285ff5cf93aafda2491e Author: dv Date: Tue Apr 7 00:00:50 2026 +0300 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d2d4277 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gititnore b/.gititnore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gititnore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..2572357 --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/__init__.py b/backend/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..e81ae9d --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/0001_initial_schema.py b/backend/alembic/versions/0001_initial_schema.py new file mode 100644 index 0000000..790613f --- /dev/null +++ b/backend/alembic/versions/0001_initial_schema.py @@ -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") diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..93d5e4a --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000..70de945 --- /dev/null +++ b/backend/app/dependencies.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..e389e46 --- /dev/null +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/middleware/auth.py b/backend/app/middleware/auth.py new file mode 100644 index 0000000..c22dd9b --- /dev/null +++ b/backend/app/middleware/auth.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..01d23d2 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..3d69d2e --- /dev/null +++ b/backend/app/models/base.py @@ -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, + ) diff --git a/backend/app/models/device.py b/backend/app/models/device.py new file mode 100644 index 0000000..79a5c55 --- /dev/null +++ b/backend/app/models/device.py @@ -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") diff --git a/backend/app/models/object.py b/backend/app/models/object.py new file mode 100644 index 0000000..d26dcdc --- /dev/null +++ b/backend/app/models/object.py @@ -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) diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py new file mode 100644 index 0000000..445f70c --- /dev/null +++ b/backend/app/models/tag.py @@ -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) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..ef30367 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..8234f1c --- /dev/null +++ b/backend/app/routers/auth.py @@ -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) diff --git a/backend/app/routers/devices.py b/backend/app/routers/devices.py new file mode 100644 index 0000000..d663ad0 --- /dev/null +++ b/backend/app/routers/devices.py @@ -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) diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py new file mode 100644 index 0000000..c1421a4 --- /dev/null +++ b/backend/app/routers/objects.py @@ -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, + ) diff --git a/backend/app/routers/tags.py b/backend/app/routers/tags.py new file mode 100644 index 0000000..78fc1cf --- /dev/null +++ b/backend/app/routers/tags.py @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..143044f --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/device.py b/backend/app/schemas/device.py new file mode 100644 index 0000000..9bb9e3a --- /dev/null +++ b/backend/app/schemas/device.py @@ -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] = [] diff --git a/backend/app/schemas/object.py b/backend/app/schemas/object.py new file mode 100644 index 0000000..1d5cbaa --- /dev/null +++ b/backend/app/schemas/object.py @@ -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] = [] diff --git a/backend/app/schemas/tag.py b/backend/app/schemas/tag.py new file mode 100644 index 0000000..664abf7 --- /dev/null +++ b/backend/app/schemas/tag.py @@ -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 diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..823dd62 --- /dev/null +++ b/backend/app/schemas/user.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py new file mode 100644 index 0000000..3091be4 --- /dev/null +++ b/backend/app/services/auth.py @@ -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() diff --git a/backend/app/services/crypto.py b/backend/app/services/crypto.py new file mode 100644 index 0000000..c766303 --- /dev/null +++ b/backend/app/services/crypto.py @@ -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() diff --git a/backend/app/services/database.py b/backend/app/services/database.py new file mode 100644 index 0000000..0893ac0 --- /dev/null +++ b/backend/app/services/database.py @@ -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, +) diff --git a/backend/app/services/object_db.py b/backend/app/services/object_db.py new file mode 100644 index 0000000..a911722 --- /dev/null +++ b/backend/app/services/object_db.py @@ -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 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..ff18171 --- /dev/null +++ b/backend/pyproject.toml @@ -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"] diff --git a/docker/Dockerfile.backend b/docker/Dockerfile.backend new file mode 100644 index 0000000..94a94e4 --- /dev/null +++ b/docker/Dockerfile.backend @@ -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"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..96191a9 --- /dev/null +++ b/docker/docker-compose.yml @@ -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: