#!/usr/bin/env python3 """ SQLAlchemy and Pydantic models for GNSS Guard Server """ from datetime import datetime from typing import Dict, Any, List, Optional from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text, Index from sqlalchemy.orm import relationship, declarative_base from pydantic import BaseModel, Field import hashlib import secrets Base = declarative_base() # ============================================================================= # SQLAlchemy Database Models # ============================================================================= class Asset(Base): """Asset (client device) registered with the server""" __tablename__ = "assets" id = Column(Integer, primary_key=True, index=True) name = Column(String(255), unique=True, nullable=False, index=True) token_hash = Column(String(64), nullable=False) # SHA-256 hash of token created_at = Column(DateTime, default=datetime.utcnow) is_active = Column(Boolean, default=True) description = Column(String(500), nullable=True) # Telegram notification settings (optional override for this asset) telegram_chat_id = Column(String(100), nullable=True) # Override default chat ID telegram_enabled = Column(Boolean, default=True) # Enable/disable notifications for this asset # Relationship to validation history validations = relationship("ValidationHistory", back_populates="asset", cascade="all, delete-orphan") # Relationship to notification state notification_state = relationship("AssetNotificationState", back_populates="asset", uselist=False, cascade="all, delete-orphan") @staticmethod def hash_token(token: str) -> str: """Hash a token using SHA-256""" return hashlib.sha256(token.encode()).hexdigest() @staticmethod def generate_token() -> str: """Generate a secure random token""" return secrets.token_urlsafe(32) def verify_token(self, token: str) -> bool: """Verify if provided token matches stored hash""" return self.token_hash == self.hash_token(token) class AssetNotificationState(Base): """Tracks the previous notification state for each asset to detect changes""" __tablename__ = "asset_notification_state" id = Column(Integer, primary_key=True, index=True) asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"), unique=True, nullable=False) # Previous state (JSON arrays stored as text) prev_sources_missing = Column(Text, nullable=True) # JSON array prev_sources_stale = Column(Text, nullable=True) # JSON array prev_threshold_breached = Column(Boolean, default=False) # Last notification timestamp last_notification_at = Column(DateTime, nullable=True) # Asset online/offline tracking is_online = Column(Boolean, default=True) # Whether asset is currently reporting last_validation_at = Column(DateTime, nullable=True) # Last time we received validation data # Relationship asset = relationship("Asset", back_populates="notification_state") class ValidationHistory(Base): """Historical validation records from assets""" __tablename__ = "validation_history" id = Column(Integer, primary_key=True, index=True) asset_id = Column(Integer, ForeignKey("assets.id", ondelete="CASCADE"), nullable=False) # Validation timestamps validation_timestamp = Column(String(50), nullable=False) # ISO format validation_timestamp_unix = Column(Float, nullable=False, index=True) # Validation result is_valid = Column(Boolean, nullable=False) # JSON fields stored as text sources_missing = Column(Text, nullable=True) # JSON array sources_stale = Column(Text, nullable=True) # JSON array coordinate_differences = Column(Text, nullable=True) # JSON object source_coordinates = Column(Text, nullable=True) # JSON object validation_details = Column(Text, nullable=True) # JSON object # Server-side metadata received_at = Column(DateTime, default=datetime.utcnow, index=True) # Relationship asset = relationship("Asset", back_populates="validations") # Indexes for common queries __table_args__ = ( Index('ix_validation_asset_timestamp', 'asset_id', 'validation_timestamp_unix'), ) # ============================================================================= # Pydantic Request/Response Models # ============================================================================= class AssetCreate(BaseModel): """Request model for creating a new asset""" name: str = Field(..., min_length=1, max_length=255) description: Optional[str] = Field(None, max_length=500) telegram_chat_id: Optional[str] = Field(None, max_length=100) # Override default chat ID telegram_enabled: bool = True # Enable notifications for this asset class AssetResponse(BaseModel): """Response model for asset data""" id: int name: str is_active: bool created_at: datetime description: Optional[str] = None telegram_chat_id: Optional[str] = None telegram_enabled: bool = True class Config: from_attributes = True class AssetWithToken(AssetResponse): """Response model for newly created asset (includes token)""" token: str # Only returned when asset is created class AssetImport(BaseModel): """Request model for importing an asset with a specific token""" name: str = Field(..., min_length=1, max_length=255) token: str = Field(..., min_length=32, max_length=128) description: Optional[str] = Field(None, max_length=500) telegram_chat_id: Optional[str] = Field(None, max_length=100) telegram_enabled: bool = True class AssetBatchImport(BaseModel): """Request model for batch importing assets""" assets: List[AssetImport] class ValidationSubmission(BaseModel): """Request model for submitting validation data""" validation_timestamp: str validation_timestamp_unix: float is_valid: bool sources_missing: List[str] = [] sources_stale: List[str] = [] coordinate_differences: Dict[str, Any] = {} source_coordinates: Dict[str, Any] = {} validation_details: Dict[str, Any] = {} class ValidationBatchSubmission(BaseModel): """Request model for submitting multiple validation records""" records: List[ValidationSubmission] class ValidationResponse(BaseModel): """Response model for validation data""" id: int asset_name: str validation_timestamp: str validation_timestamp_unix: float is_valid: bool sources_missing: List[str] sources_stale: List[str] coordinate_differences: Dict[str, Any] source_coordinates: Dict[str, Any] validation_details: Dict[str, Any] received_at: datetime class Config: from_attributes = True class AssetStatus(BaseModel): """Current status of an asset (latest validation)""" asset_name: str is_online: bool # Has reported in last 5 minutes last_seen: Optional[datetime] = None latest_validation: Optional[ValidationResponse] = None class LoginRequest(BaseModel): """Request model for user login""" username: str password: str class LoginResponse(BaseModel): """Response model for successful login""" message: str username: str