Browse Source

Create core modules and SQLAlchemy models

Core modules:
- app/config.py: Pydantic Settings for configuration
- app/core/security.py: JWT tokens and password hashing
- app/core/database.py: Async SQLAlchemy setup

Models:
- User: Superadmin and organization users (5 roles)
- Organization: Client companies with product flags
- Device: WiFi/BLE receivers with simple IDs (#1, #2, #3)
- RefreshToken: JWT refresh tokens storage
- AuditLog: User action logging

All models use timestamped Base class (created_at, updated_at)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
root 1 month ago
parent
commit
c65990d646

+ 56 - 0
backend/app/config.py

@@ -0,0 +1,56 @@
+"""
+Application configuration using Pydantic Settings.
+Environment variables are loaded from .env file.
+"""
+
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+    """Application settings loaded from environment variables."""
+
+    # Database
+    DATABASE_URL: str
+
+    # Redis
+    REDIS_URL: str = "redis://localhost:6379/0"
+
+    # Security
+    SECRET_KEY: str
+    ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
+    REFRESH_TOKEN_EXPIRE_DAYS: int = 30
+    ALGORITHM: str = "HS256"
+
+    # CORS
+    CORS_ORIGINS: str = "http://localhost:5173"
+
+    # File Storage
+    UPLOAD_DIR: str = "/var/lib/mybeacon/uploads"
+
+    # Email
+    SMTP_HOST: str = "localhost"
+    SMTP_PORT: int = 587
+    SMTP_USER: str = ""
+    SMTP_PASSWORD: str = ""
+    SMTP_FROM: str = "noreply@mybeacon.com"
+
+    # Application
+    DEBUG: bool = False
+    LOG_LEVEL: str = "INFO"
+    API_V1_PREFIX: str = "/api/v1"
+    PROJECT_NAME: str = "MyBeacon API"
+
+    model_config = SettingsConfigDict(
+        env_file=".env",
+        env_file_encoding="utf-8",
+        case_sensitive=False
+    )
+
+    @property
+    def cors_origins_list(self) -> list[str]:
+        """Parse CORS_ORIGINS string into list."""
+        return [origin.strip() for origin in self.CORS_ORIGINS.split(",")]
+
+
+# Global settings instance
+settings = Settings()

+ 61 - 0
backend/app/core/database.py

@@ -0,0 +1,61 @@
+"""
+Database configuration and session management using SQLAlchemy async.
+"""
+
+from typing import AsyncGenerator
+
+from sqlalchemy import MetaData
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+from sqlalchemy.ext.declarative import declarative_base
+from sqlalchemy.orm import sessionmaker
+
+from app.config import settings
+
+# Create async engine
+engine = create_async_engine(
+    settings.DATABASE_URL,
+    echo=settings.DEBUG,
+    future=True,
+)
+
+# Create async session factory
+async_session_maker = sessionmaker(
+    engine,
+    class_=AsyncSession,
+    expire_on_commit=False,
+    autocommit=False,
+    autoflush=False,
+)
+
+# Naming convention for constraints (for Alembic)
+convention = {
+    "ix": "ix_%(column_0_label)s",
+    "uq": "uq_%(table_name)s_%(column_0_name)s",
+    "ck": "ck_%(table_name)s_%(constraint_name)s",
+    "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
+    "pk": "pk_%(table_name)s",
+}
+
+metadata = MetaData(naming_convention=convention)
+
+# Base class for all models
+Base = declarative_base(metadata=metadata)
+
+
+async def get_db() -> AsyncGenerator[AsyncSession, None]:
+    """
+    Dependency to get database session.
+
+    Usage:
+        @app.get("/users")
+        async def get_users(db: AsyncSession = Depends(get_db)):
+            ...
+
+    Yields:
+        AsyncSession instance
+    """
+    async with async_session_maker() as session:
+        try:
+            yield session
+        finally:
+            await session.close()

+ 98 - 0
backend/app/core/security.py

@@ -0,0 +1,98 @@
+"""
+Security utilities: JWT tokens, password hashing.
+"""
+
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from jose import JWTError, jwt
+from passlib.context import CryptContext
+
+from app.config import settings
+
+# Password hashing context
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def create_access_token(data: dict[str, Any]) -> str:
+    """
+    Create JWT access token.
+
+    Args:
+        data: Payload to encode (typically {"sub": user_id})
+
+    Returns:
+        Encoded JWT token string
+    """
+    to_encode = data.copy()
+    expire = datetime.now(timezone.utc) + timedelta(
+        minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
+    )
+    to_encode.update({"exp": expire, "type": "access"})
+    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
+
+
+def create_refresh_token(data: dict[str, Any]) -> str:
+    """
+    Create JWT refresh token.
+
+    Args:
+        data: Payload to encode (typically {"sub": user_id})
+
+    Returns:
+        Encoded JWT token string
+    """
+    to_encode = data.copy()
+    expire = datetime.now(timezone.utc) + timedelta(
+        days=settings.REFRESH_TOKEN_EXPIRE_DAYS
+    )
+    to_encode.update({"exp": expire, "type": "refresh"})
+    return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
+
+
+def verify_token(token: str, expected_type: str = "access") -> dict[str, Any] | None:
+    """
+    Verify and decode JWT token.
+
+    Args:
+        token: JWT token string
+        expected_type: Expected token type ("access" or "refresh")
+
+    Returns:
+        Decoded payload if valid, None otherwise
+    """
+    try:
+        payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
+        token_type: str = payload.get("type")
+        if token_type != expected_type:
+            return None
+        return payload
+    except JWTError:
+        return None
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+    """
+    Verify a plain password against a hashed password.
+
+    Args:
+        plain_password: Plain text password
+        hashed_password: Hashed password from database
+
+    Returns:
+        True if password matches, False otherwise
+    """
+    return pwd_context.verify(plain_password, hashed_password)
+
+
+def hash_password(password: str) -> str:
+    """
+    Hash a password using bcrypt.
+
+    Args:
+        password: Plain text password
+
+    Returns:
+        Hashed password
+    """
+    return pwd_context.hash(password)

+ 21 - 0
backend/app/models/__init__.py

@@ -0,0 +1,21 @@
+"""
+SQLAlchemy models.
+
+Import all models here so Alembic can discover them.
+"""
+
+from app.models.audit_log import AuditLog
+from app.models.base import Base
+from app.models.device import Device
+from app.models.organization import Organization
+from app.models.refresh_token import RefreshToken
+from app.models.user import User
+
+__all__ = [
+    "Base",
+    "User",
+    "Organization",
+    "Device",
+    "RefreshToken",
+    "AuditLog",
+]

+ 59 - 0
backend/app/models/audit_log.py

@@ -0,0 +1,59 @@
+"""
+AuditLog model - tracks all user actions.
+"""
+
+from sqlalchemy import BigInteger, ForeignKey, Integer, String
+from sqlalchemy.dialects.postgresql import INET, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class AuditLog(Base):
+    """
+    Audit log model for tracking user actions.
+
+    Logs every action: login, view, create, update, delete, export, etc.
+    """
+
+    __tablename__ = "audit_logs"
+
+    id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
+
+    # Who
+    user_id: Mapped[int | None] = mapped_column(
+        ForeignKey("users.id", ondelete="SET NULL")
+    )
+    user_email: Mapped[str | None] = mapped_column(
+        String(255)
+    )  # Cached for deleted users
+
+    organization_id: Mapped[int | None] = mapped_column(
+        ForeignKey("organizations.id", ondelete="SET NULL")
+    )
+
+    # What
+    action: Mapped[str] = mapped_column(
+        String(50), nullable=False
+    )  # login, logout, failed_login, view, create, update, delete, export, etc.
+
+    resource_type: Mapped[str | None] = mapped_column(
+        String(50)
+    )  # device, user, location, beacon, etc.
+
+    resource_id: Mapped[int | None] = mapped_column(Integer)
+
+    # Details
+    description: Mapped[str | None] = mapped_column(String)
+    changes: Mapped[dict | None] = mapped_column(JSONB)  # Before/after values
+
+    # When & Where
+    ip_address: Mapped[str | None] = mapped_column(INET)
+    user_agent: Mapped[str | None] = mapped_column(String)
+
+    # Relationships
+    user: Mapped["User | None"] = relationship("User", back_populates="audit_logs")
+    organization: Mapped["Organization | None"] = relationship("Organization")
+
+    def __repr__(self) -> str:
+        return f"<AuditLog {self.id}: {self.action} by {self.user_email}>"

+ 32 - 0
backend/app/models/base.py

@@ -0,0 +1,32 @@
+"""
+Base model with common fields for all models.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime
+from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.sql import func
+
+from app.core.database import Base as SQLAlchemyBase
+
+
+class Base(SQLAlchemyBase):
+    """
+    Base class for all database models.
+    Provides common fields: created_at, updated_at.
+    """
+
+    __abstract__ = True
+
+    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,
+    )

+ 69 - 0
backend/app/models/device.py

@@ -0,0 +1,69 @@
+"""
+Device model - WiFi/BLE receivers/scanners.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String
+from sqlalchemy.dialects.postgresql import INET, JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class Device(Base):
+    """
+    Device model - WiFi/BLE receivers/scanners.
+
+    Uses simple_id for customer support (Receiver #1, #2, #3...)
+    instead of MAC addresses.
+    """
+
+    __tablename__ = "devices"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+
+    # Simple ID for customer support (auto-increment, never reused)
+    simple_id: Mapped[int] = mapped_column(Integer, unique=True, nullable=False)
+
+    # Hardware identifiers
+    mac_address: Mapped[str] = mapped_column(String(17), unique=True, nullable=False)
+    serial_number: Mapped[str | None] = mapped_column(String(100))
+
+    # Device info
+    device_type: Mapped[str] = mapped_column(
+        String(50), default="combo", nullable=False
+    )  # wifi_scanner, ble_receiver, combo
+    model: Mapped[str | None] = mapped_column(String(50))
+    firmware_version: Mapped[str | None] = mapped_column(String(50))
+
+    # Organization binding (NULL = unassigned)
+    organization_id: Mapped[int | None] = mapped_column(
+        ForeignKey("organizations.id", ondelete="SET NULL")
+    )
+
+    # Status: offline, online, error
+    status: Mapped[str] = mapped_column(
+        String(20), default="offline", nullable=False
+    )
+    last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+    last_ip: Mapped[str | None] = mapped_column(INET)
+
+    # Config (flexible JSON for different device types)
+    config: Mapped[dict] = mapped_column(JSONB, default={}, nullable=False)
+
+    # Notes
+    notes: Mapped[str | None] = mapped_column(String)
+
+    # Relationships
+    organization: Mapped["Organization | None"] = relationship(
+        "Organization", back_populates="devices"
+    )
+
+    @property
+    def display_name(self) -> str:
+        """Display name: Receiver #5"""
+        return f"Receiver #{self.simple_id}"
+
+    def __repr__(self) -> str:
+        return f"<Device {self.id}: {self.display_name} ({self.mac_address})>"

+ 47 - 0
backend/app/models/organization.py

@@ -0,0 +1,47 @@
+"""
+Organization model - represents a client company.
+"""
+
+from sqlalchemy import Boolean, String
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class Organization(Base):
+    """
+    Organization (client company) model.
+
+    Each organization can have multiple users and devices.
+    Products (WiFi, BLE) are enabled/disabled per organization.
+    """
+
+    __tablename__ = "organizations"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255), nullable=False)
+    contact_email: Mapped[str] = mapped_column(String(255), nullable=False)
+    contact_phone: Mapped[str | None] = mapped_column(String(50))
+
+    # Product access (modular)
+    wifi_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+    ble_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+
+    # Status: pending, active, suspended, deleted
+    status: Mapped[str] = mapped_column(
+        String(20), default="pending", nullable=False
+    )
+
+    # Admin notes
+    notes: Mapped[str | None] = mapped_column(String)
+
+    # Relationships
+    users: Mapped[list["User"]] = relationship(
+        "User", back_populates="organization", cascade="all, delete-orphan"
+    )
+    devices: Mapped[list["Device"]] = relationship(
+        "Device", back_populates="organization"
+    )
+
+    def __repr__(self) -> str:
+        return f"<Organization {self.id}: {self.name}>"

+ 52 - 0
backend/app/models/refresh_token.py

@@ -0,0 +1,52 @@
+"""
+RefreshToken model - stores refresh tokens for users.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, String
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class RefreshToken(Base):
+    """
+    Refresh token model for JWT token rotation.
+
+    Stores hashed refresh tokens and device info.
+    Tokens can be revoked by setting revoked_at.
+    """
+
+    __tablename__ = "refresh_tokens"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    user_id: Mapped[int] = mapped_column(
+        ForeignKey("users.id", ondelete="CASCADE"), nullable=False
+    )
+
+    # Token (should be hashed in production, but for MVP we'll store plain)
+    token: Mapped[str] = mapped_column(String(512), unique=True, nullable=False)
+
+    expires_at: Mapped[datetime] = mapped_column(
+        DateTime(timezone=True), nullable=False
+    )
+
+    # Device tracking (user agent, IP, etc.)
+    device_info: Mapped[dict | None] = mapped_column(JSONB)
+
+    # Revocation
+    revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+
+    # Relationships
+    user: Mapped["User"] = relationship("User", back_populates="refresh_tokens")
+
+    @property
+    def is_valid(self) -> bool:
+        """Check if token is valid (not expired and not revoked)."""
+        now = datetime.now()
+        return self.expires_at > now and self.revoked_at is None
+
+    def __repr__(self) -> str:
+        return f"<RefreshToken {self.id} for user {self.user_id}>"

+ 85 - 0
backend/app/models/user.py

@@ -0,0 +1,85 @@
+"""
+User model - superadmin and organization users.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, String
+from sqlalchemy.dialects.postgresql import INET
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from app.models.base import Base
+
+
+class User(Base):
+    """
+    User model for both superadmin and organization users.
+
+    Roles:
+    - superadmin: Full system access (organization_id = NULL)
+    - owner: Organization owner (can manage users)
+    - admin: Full organization access (cannot manage users)
+    - manager: Read-only analytics access
+    - operator: Access to devices and beacons
+    - viewer: Read-only access
+    """
+
+    __tablename__ = "users"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
+    hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
+    full_name: Mapped[str | None] = mapped_column(String(255))
+    phone: Mapped[str | None] = mapped_column(String(50))
+
+    # Role: superadmin, owner, admin, manager, operator, viewer
+    role: Mapped[str] = mapped_column(String(20), nullable=False)
+
+    # Status: pending, active, suspended, deleted
+    status: Mapped[str] = mapped_column(
+        String(20), default="pending", nullable=False
+    )
+
+    # Organization (NULL for superadmin)
+    organization_id: Mapped[int | None] = mapped_column(
+        ForeignKey("organizations.id", ondelete="CASCADE")
+    )
+
+    # Email verification
+    email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+    email_verification_token: Mapped[str | None] = mapped_column(String(255))
+    email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+
+    # Password reset
+    password_reset_token: Mapped[str | None] = mapped_column(String(255))
+    password_reset_expires: Mapped[datetime | None] = mapped_column(
+        DateTime(timezone=True)
+    )
+
+    # Login tracking
+    last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+    last_login_ip: Mapped[str | None] = mapped_column(INET)
+
+    # Relationships
+    organization: Mapped["Organization | None"] = relationship(
+        "Organization", back_populates="users"
+    )
+    refresh_tokens: Mapped[list["RefreshToken"]] = relationship(
+        "RefreshToken", back_populates="user", cascade="all, delete-orphan"
+    )
+    audit_logs: Mapped[list["AuditLog"]] = relationship(
+        "AuditLog", back_populates="user"
+    )
+
+    @property
+    def is_superadmin(self) -> bool:
+        """Check if user is superadmin."""
+        return self.role == "superadmin"
+
+    @property
+    def is_owner(self) -> bool:
+        """Check if user is organization owner."""
+        return self.role == "owner"
+
+    def __repr__(self) -> str:
+        return f"<User {self.id}: {self.email} ({self.role})>"