- Add BEIJING_TZ constant in config.py - Replace all timezone.utc references across 11 files - Device-reported times, DB defaults, protocol sync all use Beijing time via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
345 lines
16 KiB
Python
345 lines
16 KiB
Python
from datetime import datetime
|
|
|
|
from app.config import BEIJING_TZ
|
|
|
|
from sqlalchemy import (
|
|
BigInteger,
|
|
Boolean,
|
|
DateTime,
|
|
Float,
|
|
ForeignKey,
|
|
Index,
|
|
Integer,
|
|
String,
|
|
Text,
|
|
)
|
|
from sqlalchemy.dialects.sqlite import JSON
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from app.database import Base
|
|
|
|
|
|
def _now_beijing() -> datetime:
|
|
return datetime.now(BEIJING_TZ)
|
|
|
|
|
|
class Device(Base):
|
|
"""Registered Bluetooth badge devices."""
|
|
|
|
__tablename__ = "devices"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
imei: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
|
|
device_type: Mapped[str] = mapped_column(String(10), nullable=False)
|
|
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
status: Mapped[str] = mapped_column(String(20), default="offline", nullable=False)
|
|
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
last_heartbeat: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
last_login: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
iccid: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
|
imsi: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
timezone: Mapped[str] = mapped_column(String(30), default="+8", nullable=False)
|
|
language: Mapped[str] = mapped_column(String(10), default="cn", nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime, default=_now_beijing, onupdate=_now_beijing, nullable=True
|
|
)
|
|
|
|
# Relationships
|
|
locations: Mapped[list["LocationRecord"]] = relationship(
|
|
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
|
)
|
|
alarms: Mapped[list["AlarmRecord"]] = relationship(
|
|
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
|
)
|
|
heartbeats: Mapped[list["HeartbeatRecord"]] = relationship(
|
|
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
|
)
|
|
attendance_records: Mapped[list["AttendanceRecord"]] = relationship(
|
|
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
|
)
|
|
bluetooth_records: Mapped[list["BluetoothRecord"]] = relationship(
|
|
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
|
)
|
|
command_logs: Mapped[list["CommandLog"]] = relationship(
|
|
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<Device(id={self.id}, imei={self.imei}, status={self.status})>"
|
|
|
|
|
|
class LocationRecord(Base):
|
|
"""GPS / LBS / WIFI location records."""
|
|
|
|
__tablename__ = "location_records"
|
|
__table_args__ = (
|
|
Index("ix_location_device_time", "device_id", "recorded_at"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
device_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
|
)
|
|
location_type: Mapped[str] = mapped_column(
|
|
String(10), nullable=False
|
|
) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g
|
|
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
course: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
gps_satellites: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
rssi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
neighbor_cells: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
report_mode: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
is_realtime: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
mileage: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
raw_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
|
|
|
|
device: Mapped["Device"] = relationship(back_populates="locations")
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"<LocationRecord(id={self.id}, device_id={self.device_id}, "
|
|
f"type={self.location_type})>"
|
|
)
|
|
|
|
|
|
class AlarmRecord(Base):
|
|
"""Alarm events raised by devices."""
|
|
|
|
__tablename__ = "alarm_records"
|
|
__table_args__ = (
|
|
Index("ix_alarm_device_time", "device_id", "recorded_at"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
device_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
|
)
|
|
alarm_type: Mapped[str] = mapped_column(
|
|
String(30), nullable=False
|
|
) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ...
|
|
alarm_source: Mapped[str | None] = mapped_column(
|
|
String(20), nullable=True
|
|
) # single_fence, multi_fence, lbs, wifi
|
|
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
course: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
fence_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
acknowledged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
|
|
|
|
device: Mapped["Device"] = relationship(back_populates="alarms")
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"<AlarmRecord(id={self.id}, device_id={self.device_id}, "
|
|
f"type={self.alarm_type})>"
|
|
)
|
|
|
|
|
|
class HeartbeatRecord(Base):
|
|
"""Heartbeat history from devices."""
|
|
|
|
__tablename__ = "heartbeat_records"
|
|
__table_args__ = (
|
|
Index("ix_heartbeat_device_time", "device_id", "created_at"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
device_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
|
)
|
|
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) # 0x13 or 0x36
|
|
terminal_info: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
battery_level: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
gsm_signal: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
extension_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
|
|
|
|
device: Mapped["Device"] = relationship(back_populates="heartbeats")
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<HeartbeatRecord(id={self.id}, device_id={self.device_id})>"
|
|
|
|
|
|
class AttendanceRecord(Base):
|
|
"""Attendance punch records from badges."""
|
|
|
|
__tablename__ = "attendance_records"
|
|
__table_args__ = (
|
|
Index("ix_attendance_device_time", "device_id", "recorded_at"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
device_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
|
)
|
|
attendance_type: Mapped[str] = mapped_column(
|
|
String(20), nullable=False
|
|
) # clock_in, clock_out
|
|
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
|
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
course: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
gps_satellites: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
|
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
lbs_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
|
|
|
|
device: Mapped["Device"] = relationship(back_populates="attendance_records")
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"<AttendanceRecord(id={self.id}, device_id={self.device_id}, "
|
|
f"type={self.attendance_type})>"
|
|
)
|
|
|
|
|
|
class BluetoothRecord(Base):
|
|
"""Bluetooth punch card and location records."""
|
|
|
|
__tablename__ = "bluetooth_records"
|
|
__table_args__ = (
|
|
Index("ix_bluetooth_device_time", "device_id", "recorded_at"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
device_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
|
)
|
|
record_type: Mapped[str] = mapped_column(
|
|
String(20), nullable=False
|
|
) # punch, location
|
|
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
beacon_mac: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
beacon_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
|
beacon_major: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
beacon_minor: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
rssi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
beacon_battery: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
beacon_battery_unit: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
|
attendance_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
bluetooth_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
|
|
|
|
device: Mapped["Device"] = relationship(back_populates="bluetooth_records")
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"<BluetoothRecord(id={self.id}, device_id={self.device_id}, "
|
|
f"type={self.record_type})>"
|
|
)
|
|
|
|
|
|
class BeaconConfig(Base):
|
|
"""Registered Bluetooth beacon configuration for indoor positioning."""
|
|
|
|
__tablename__ = "beacon_configs"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
beacon_mac: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
|
|
beacon_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
|
beacon_major: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
beacon_minor: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
floor: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
|
area: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
|
|
updated_at: Mapped[datetime | None] = mapped_column(
|
|
DateTime, default=_now_beijing, onupdate=_now_beijing, nullable=True
|
|
)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
|
|
|
|
|
|
class CommandLog(Base):
|
|
"""Log of commands sent to devices."""
|
|
|
|
__tablename__ = "command_logs"
|
|
__table_args__ = (
|
|
Index("ix_command_device_status", "device_id", "status"),
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
device_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
|
)
|
|
command_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
|
command_content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
server_flag: Mapped[str] = mapped_column(String(20), nullable=False)
|
|
response_content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
status: Mapped[str] = mapped_column(
|
|
String(20), default="pending", nullable=False
|
|
) # pending, sent, success, failed
|
|
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
response_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
|
|
|
|
device: Mapped["Device"] = relationship(back_populates="command_logs")
|
|
|
|
def __repr__(self) -> str:
|
|
return (
|
|
f"<CommandLog(id={self.id}, device_id={self.device_id}, "
|
|
f"type={self.command_type}, status={self.status})>"
|
|
)
|
|
|
|
|
|
class ApiKey(Base):
|
|
"""API keys for external system authentication."""
|
|
|
|
__tablename__ = "api_keys"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
|
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
|
permissions: Mapped[str] = mapped_column(
|
|
String(20), default="read", nullable=False
|
|
) # read, write, admin
|
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_now_beijing, nullable=False)
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<ApiKey(id={self.id}, name={self.name}, permissions={self.permissions})>"
|