● Zion Boggan
repos/Sightline/backend/app/models/__init__.py
zionboggan.com ↗
481 lines · python
History for this file →
1
"""All ORM models for Sightline. Kept in one module to keep relationships coherent
2
and avoid circular imports in a scaffold of this size."""
3
from __future__ import annotations
4
 
5
import uuid
6
from datetime import datetime
7
 
8
from sqlalchemy import (
9
    BigInteger, Boolean, CheckConstraint, Date, DateTime, Float, ForeignKey,
10
    Index, Integer, JSON, LargeBinary, Numeric, String, Text, UniqueConstraint, func,
11
)
12
from sqlalchemy.orm import Mapped, mapped_column, relationship
13
 
14
from app.db.base import Base, TimestampMixin
15
 
16
 
17
def _uuid() -> str:
18
    return str(uuid.uuid4())
19
 
20
 
21
class Role(Base, TimestampMixin):
22
    __tablename__ = "roles"
23
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
24
    name: Mapped[str] = mapped_column(String, unique=True, nullable=False)
25
    permissions: Mapped[dict] = mapped_column(JSON, default=dict)
26
 
27
 
28
class User(Base, TimestampMixin):
29
    __tablename__ = "users"
30
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
31
    email: Mapped[str] = mapped_column(String, unique=True, nullable=False, index=True)
32
    hashed_password: Mapped[str] = mapped_column(String, nullable=False)
33
    full_name: Mapped[str | None] = mapped_column(String)
34
    role: Mapped[str] = mapped_column(String, default="analyst", nullable=False)
35
    is_active: Mapped[bool] = mapped_column(Boolean, default=True)
36
 
37
 
38
class ApiKey(Base, TimestampMixin):
39
    __tablename__ = "api_keys"
40
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
41
    user_id: Mapped[str] = mapped_column(ForeignKey("users.id"))
42
    name: Mapped[str] = mapped_column(String)
43
    key_hash: Mapped[str] = mapped_column(String, index=True)
44
    scopes: Mapped[dict] = mapped_column(JSON, default=dict)
45
    last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
46
    revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
47
 
48
 
49
class AuditLog(Base):
50
    __tablename__ = "audit_logs"
51
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
52
    actor_user_id: Mapped[str | None] = mapped_column(String, index=True)
53
    action: Mapped[str] = mapped_column(String, nullable=False)
54
    entity_type: Mapped[str | None] = mapped_column(String)
55
    entity_id: Mapped[str | None] = mapped_column(String)
56
    ip: Mapped[str | None] = mapped_column(String)
57
    meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
58
    created_at: Mapped[datetime] = mapped_column(
59
        DateTime(timezone=True), server_default=func.now(), index=True
60
    )
61
    __table_args__ = (Index("ix_audit_entity", "entity_type", "entity_id"),)
62
 
63
 
64
class Source(Base, TimestampMixin):
65
    __tablename__ = "sources"
66
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
67
    name: Mapped[str] = mapped_column(String, nullable=False)
68
    system_type: Mapped[str | None] = mapped_column(String)
69
    organization: Mapped[str | None] = mapped_column(String)
70
    jurisdiction: Mapped[str | None] = mapped_column(String)
71
    trust_level: Mapped[float] = mapped_column(Float, default=0.8)
72
 
73
 
74
class Import(Base, TimestampMixin):
75
    __tablename__ = "imports"
76
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
77
    source_id: Mapped[str | None] = mapped_column(ForeignKey("sources.id"))
78
    filename: Mapped[str] = mapped_column(String)
79
    content_type: Mapped[str | None] = mapped_column(String)
80
    sha256: Mapped[str] = mapped_column(String, unique=True, index=True)
81
    raw_blob_ref: Mapped[str | None] = mapped_column(String)
82
    row_count: Mapped[int] = mapped_column(Integer, default=0)
83
    status: Mapped[str] = mapped_column(String, default="received")
84
    error: Mapped[str | None] = mapped_column(Text)
85
    created_by: Mapped[str | None] = mapped_column(String)
86
 
87
 
88
class Location(Base, TimestampMixin):
89
    __tablename__ = "locations"
90
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
91
    label: Mapped[str | None] = mapped_column(String)
92
    address: Mapped[str | None] = mapped_column(String)
93
    lat: Mapped[float | None] = mapped_column(Float)
94
    lon: Mapped[float | None] = mapped_column(Float)
95
    jurisdiction: Mapped[str | None] = mapped_column(String)
96
    __table_args__ = (Index("ix_locations_latlon", "lat", "lon"),)
97
 
98
 
99
class CameraGroup(Base, TimestampMixin):
100
    __tablename__ = "camera_groups"
101
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
102
    name: Mapped[str] = mapped_column(String, nullable=False)
103
    organization: Mapped[str | None] = mapped_column(String)
104
    description: Mapped[str | None] = mapped_column(Text)
105
 
106
 
107
class Camera(Base, TimestampMixin):
108
    __tablename__ = "cameras"
109
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
110
    source_id: Mapped[str | None] = mapped_column(ForeignKey("sources.id"))
111
    external_camera_id: Mapped[str] = mapped_column(String, index=True)
112
    name: Mapped[str | None] = mapped_column(String)
113
    direction_facing: Mapped[str | None] = mapped_column(String)
114
    operator: Mapped[str | None] = mapped_column(String, index=True)
115
    manufacturer: Mapped[str | None] = mapped_column(String)
116
    zone: Mapped[str | None] = mapped_column(String)
117
    state: Mapped[str | None] = mapped_column(String, index=True)
118
    osm_id: Mapped[str | None] = mapped_column(String)
119
    image_url: Mapped[str | None] = mapped_column(String)
120
    has_live_feed: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
121
    live_source: Mapped[str | None] = mapped_column(String)
122
    area: Mapped[str | None] = mapped_column(String)
123
    location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"))
124
    camera_group_id: Mapped[str | None] = mapped_column(ForeignKey("camera_groups.id"))
125
    meta: Mapped[dict] = mapped_column("metadata", JSON, default=dict)
126
    location: Mapped[Location | None] = relationship()
127
    __table_args__ = (UniqueConstraint("source_id", "external_camera_id", name="uq_camera_source_extid"),)
128
 
129
 
130
class LicensePlate(Base, TimestampMixin):
131
    __tablename__ = "license_plates"
132
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
133
    plate_raw: Mapped[str] = mapped_column(String)
134
    plate_norm: Mapped[str] = mapped_column(String, index=True, nullable=False)
135
    state: Mapped[str | None] = mapped_column(String)
136
    plate_type: Mapped[str | None] = mapped_column(String)
137
    __table_args__ = (UniqueConstraint("plate_norm", "state", name="uq_plate_norm_state"),)
138
 
139
 
140
class Vehicle(Base, TimestampMixin):
141
    __tablename__ = "vehicles"
142
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
143
    primary_plate_id: Mapped[str | None] = mapped_column(ForeignKey("license_plates.id"))
144
    make: Mapped[str | None] = mapped_column(String)
145
    model: Mapped[str | None] = mapped_column(String)
146
    color: Mapped[str | None] = mapped_column(String)
147
    body_type: Mapped[str | None] = mapped_column(String)
148
    year: Mapped[int | None] = mapped_column(Integer)
149
    notes: Mapped[str | None] = mapped_column(Text)
150
    possible_duplicate_of: Mapped[str | None] = mapped_column(ForeignKey("vehicles.id"))
151
 
152
 
153
class PlateRead(Base):
154
    __tablename__ = "plate_reads"
155
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
156
    import_id: Mapped[str | None] = mapped_column(ForeignKey("imports.id"))
157
    source_id: Mapped[str | None] = mapped_column(ForeignKey("sources.id"))
158
    camera_id: Mapped[str | None] = mapped_column(ForeignKey("cameras.id"), index=True)
159
    plate_id: Mapped[str | None] = mapped_column(ForeignKey("license_plates.id"))
160
    plate_norm: Mapped[str] = mapped_column(String, index=True, nullable=False)
161
    read_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True, nullable=False)
162
    direction: Mapped[str | None] = mapped_column(String)
163
    confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
164
    lane: Mapped[str | None] = mapped_column(String)
165
    lat: Mapped[float | None] = mapped_column(Float)
166
    lon: Mapped[float | None] = mapped_column(Float)
167
    hotlist_hit: Mapped[bool] = mapped_column(Boolean, default=False)
168
    incident_ref: Mapped[str | None] = mapped_column(String)
169
    burst_count: Mapped[int] = mapped_column(Integer, default=1)
170
    raw_read: Mapped[dict] = mapped_column(JSON, default=dict)
171
    dedup_hash: Mapped[bytes] = mapped_column(LargeBinary, unique=True)
172
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
173
    __table_args__ = (Index("ix_reads_ts_camera", "read_ts", "camera_id"),)
174
 
175
 
176
class Sighting(Base):
177
    __tablename__ = "sightings"
178
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
179
    plate_read_id: Mapped[int | None] = mapped_column(ForeignKey("plate_reads.id"))
180
    vehicle_id: Mapped[str | None] = mapped_column(ForeignKey("vehicles.id"), index=True)
181
    camera_id: Mapped[str | None] = mapped_column(ForeignKey("cameras.id"))
182
    location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"))
183
    investigation_id: Mapped[str | None] = mapped_column(ForeignKey("investigations.id"), index=True)
184
    read_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
185
    confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
186
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
187
    __table_args__ = (Index("ix_sightings_vehicle_ts", "vehicle_id", "read_ts"),)
188
 
189
 
190
class Person(Base, TimestampMixin):
191
    __tablename__ = "persons"
192
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
193
    full_name: Mapped[str] = mapped_column(String, index=True, nullable=False)
194
    aliases: Mapped[list | None] = mapped_column(JSON, default=list)
195
    dob: Mapped[str | None] = mapped_column(String)
196
    sex: Mapped[str | None] = mapped_column(String)
197
    height_cm: Mapped[int | None] = mapped_column(Integer)
198
    build: Mapped[str | None] = mapped_column(String)
199
    hair: Mapped[str | None] = mapped_column(String)
200
    eyes: Mapped[str | None] = mapped_column(String)
201
    distinguishing_marks: Mapped[str | None] = mapped_column(Text)
202
    associated_plate: Mapped[str | None] = mapped_column(String, index=True)
203
    associated_plates: Mapped[list | None] = mapped_column(JSON, default=list)
204
    threat_level: Mapped[str] = mapped_column(String, default="none")
205
    wanted: Mapped[bool] = mapped_column(Boolean, default=False)
206
    consented: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
207
    notes: Mapped[str | None] = mapped_column(Text)
208
 
209
 
210
class AppSetting(Base):
211
    """Mutable runtime settings (singleton-ish key/value). Used for the profile-engine scope
212
    switch so an operator can lock the dossier builder to consented subjects only."""
213
    __tablename__ = "app_settings"
214
    key: Mapped[str] = mapped_column(String, primary_key=True)
215
    value: Mapped[dict] = mapped_column(JSON, default=dict)
216
 
217
 
218
class Detection(Base):
219
    """A person/occupant observation at a camera, carrying soft-biometric and clothing
220
    descriptors - supports search by name, outfit, or article of clothing. (In a real deployment
221
    these come from video analytics; in the POC they are synthetic but realistically structured.)"""
222
    __tablename__ = "detections"
223
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
224
    person_id: Mapped[str | None] = mapped_column(ForeignKey("persons.id"), index=True)
225
    camera_id: Mapped[str | None] = mapped_column(ForeignKey("cameras.id"), index=True)
226
    sighting_id: Mapped[int | None] = mapped_column(ForeignKey("sightings.id"))
227
    read_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
228
    plate_norm: Mapped[str | None] = mapped_column(String, index=True)
229
    occupant_role: Mapped[str | None] = mapped_column(String)
230
    clothing_top: Mapped[str | None] = mapped_column(String, index=True)
231
    clothing_bottom: Mapped[str | None] = mapped_column(String)
232
    articles: Mapped[list | None] = mapped_column(JSON, default=list)
233
    description: Mapped[str | None] = mapped_column(Text)
234
    confidence: Mapped[float | None] = mapped_column(Numeric(4, 3))
235
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
236
 
237
 
238
class Investigation(Base, TimestampMixin):
239
    __tablename__ = "investigations"
240
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
241
    title: Mapped[str] = mapped_column(String, nullable=False)
242
    status: Mapped[str] = mapped_column(String, default="open")
243
    priority: Mapped[str] = mapped_column(String, default="normal")
244
    summary: Mapped[str | None] = mapped_column(Text)
245
    lead_user_id: Mapped[str | None] = mapped_column(String)
246
    organization: Mapped[str | None] = mapped_column(String)
247
 
248
 
249
class Target(Base, TimestampMixin):
250
    __tablename__ = "targets"
251
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
252
    investigation_id: Mapped[str] = mapped_column(ForeignKey("investigations.id"))
253
    kind: Mapped[str] = mapped_column(String)
254
    label: Mapped[str] = mapped_column(String)
255
    plate_id: Mapped[str | None] = mapped_column(ForeignKey("license_plates.id"))
256
    vehicle_id: Mapped[str | None] = mapped_column(ForeignKey("vehicles.id"))
257
    osint_entity_id: Mapped[str | None] = mapped_column(ForeignKey("osint_entities.id"))
258
    notes: Mapped[str | None] = mapped_column(Text)
259
 
260
 
261
class Incident(Base, TimestampMixin):
262
    __tablename__ = "incidents"
263
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
264
    external_ref: Mapped[str | None] = mapped_column(String)
265
    title: Mapped[str] = mapped_column(String)
266
    kind: Mapped[str | None] = mapped_column(String)
267
    occurred_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
268
    location_id: Mapped[str | None] = mapped_column(ForeignKey("locations.id"))
269
    description: Mapped[str | None] = mapped_column(Text)
270
 
271
 
272
class Note(Base, TimestampMixin):
273
    __tablename__ = "notes"
274
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
275
    investigation_id: Mapped[str] = mapped_column(ForeignKey("investigations.id"))
276
    author_id: Mapped[str | None] = mapped_column(String)
277
    body: Mapped[str] = mapped_column(Text)
278
    pinned: Mapped[bool] = mapped_column(Boolean, default=False)
279
 
280
 
281
class Tag(Base, TimestampMixin):
282
    __tablename__ = "tags"
283
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
284
    name: Mapped[str] = mapped_column(String, unique=True)
285
    color: Mapped[str | None] = mapped_column(String)
286
 
287
 
288
class Tagging(Base):
289
    __tablename__ = "taggings"
290
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
291
    tag_id: Mapped[str] = mapped_column(ForeignKey("tags.id"))
292
    entity_type: Mapped[str] = mapped_column(String)
293
    entity_id: Mapped[str] = mapped_column(String)
294
    __table_args__ = (Index("ix_taggings_entity", "entity_type", "entity_id"),)
295
 
296
 
297
class EvidenceFile(Base, TimestampMixin):
298
    __tablename__ = "evidence_files"
299
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
300
    investigation_id: Mapped[str] = mapped_column(ForeignKey("investigations.id"))
301
    filename: Mapped[str] = mapped_column(String)
302
    content_type: Mapped[str | None] = mapped_column(String)
303
    size_bytes: Mapped[int] = mapped_column(Integer, default=0)
304
    sha256: Mapped[str] = mapped_column(String, index=True)
305
    blob_ref: Mapped[str | None] = mapped_column(String)
306
    source_id: Mapped[str | None] = mapped_column(ForeignKey("sources.id"))
307
    description: Mapped[str | None] = mapped_column(Text)
308
    uploaded_by: Mapped[str | None] = mapped_column(String)
309
 
310
 
311
class Watchlist(Base, TimestampMixin):
312
    __tablename__ = "watchlists"
313
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
314
    name: Mapped[str] = mapped_column(String, nullable=False)
315
    organization: Mapped[str | None] = mapped_column(String)
316
    kind: Mapped[str] = mapped_column(String, default="plate")
317
    active: Mapped[bool] = mapped_column(Boolean, default=True)
318
    created_by: Mapped[str | None] = mapped_column(String)
319
 
320
 
321
class WatchlistEntry(Base, TimestampMixin):
322
    __tablename__ = "watchlist_entries"
323
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
324
    watchlist_id: Mapped[str] = mapped_column(ForeignKey("watchlists.id"))
325
    plate_norm: Mapped[str | None] = mapped_column(String, index=True)
326
    partial_plate: Mapped[str | None] = mapped_column(String)
327
    vehicle_desc: Mapped[dict | None] = mapped_column(JSON)
328
    reason: Mapped[str | None] = mapped_column(Text)
329
    expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
330
 
331
 
332
class WatchlistHit(Base):
333
    __tablename__ = "watchlist_hits"
334
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
335
    watchlist_entry_id: Mapped[str] = mapped_column(ForeignKey("watchlist_entries.id"))
336
    plate_read_id: Mapped[int | None] = mapped_column(ForeignKey("plate_reads.id"))
337
    sighting_id: Mapped[int | None] = mapped_column(ForeignKey("sightings.id"))
338
    matched_on: Mapped[str] = mapped_column(String)
339
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
340
 
341
 
342
class AlertRule(Base, TimestampMixin):
343
    __tablename__ = "alert_rules"
344
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
345
    watchlist_id: Mapped[str | None] = mapped_column(ForeignKey("watchlists.id"))
346
    condition: Mapped[dict] = mapped_column(JSON, default=dict)
347
    channel: Mapped[str] = mapped_column(String, default="in_app")
348
    throttle_seconds: Mapped[int] = mapped_column(Integer, default=0)
349
    active: Mapped[bool] = mapped_column(Boolean, default=True)
350
 
351
 
352
class Alert(Base):
353
    __tablename__ = "alerts"
354
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
355
    alert_rule_id: Mapped[str | None] = mapped_column(ForeignKey("alert_rules.id"))
356
    watchlist_hit_id: Mapped[int | None] = mapped_column(ForeignKey("watchlist_hits.id"))
357
    status: Mapped[str] = mapped_column(String, default="new")
358
    payload: Mapped[dict] = mapped_column(JSON, default=dict)
359
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
360
 
361
 
362
class OsintEntity(Base, TimestampMixin):
363
    __tablename__ = "osint_entities"
364
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
365
    kind: Mapped[str] = mapped_column(String)
366
    value: Mapped[str] = mapped_column(String)
367
    attributes: Mapped[dict] = mapped_column(JSON, default=dict)
368
    first_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
369
    last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
370
    __table_args__ = (UniqueConstraint("kind", "value", name="uq_osint_kind_value"),)
371
 
372
 
373
class EntityRelationship(Base):
374
    __tablename__ = "entity_relationships"
375
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
376
    src_type: Mapped[str] = mapped_column(String)
377
    src_id: Mapped[str] = mapped_column(String)
378
    dst_type: Mapped[str] = mapped_column(String)
379
    dst_id: Mapped[str] = mapped_column(String)
380
    relation: Mapped[str] = mapped_column(String)
381
    weight: Mapped[float] = mapped_column(Float, default=1.0)
382
    confidence: Mapped[float | None] = mapped_column(Float)
383
    evidence_ref: Mapped[str | None] = mapped_column(String)
384
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
385
    __table_args__ = (
386
        Index("ix_rel_src", "src_type", "src_id"),
387
        Index("ix_rel_dst", "dst_type", "dst_id"),
388
    )
389
 
390
 
391
class EnrichmentJob(Base):
392
    __tablename__ = "enrichment_jobs"
393
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
394
    investigation_id: Mapped[str | None] = mapped_column(ForeignKey("investigations.id"))
395
    enricher: Mapped[str] = mapped_column(String)
396
    target: Mapped[str] = mapped_column(String)
397
    status: Mapped[str] = mapped_column(String, default="pending")
398
    result: Mapped[dict | None] = mapped_column(JSON)
399
    confidence: Mapped[float | None] = mapped_column(Float)
400
    raw_response_ref: Mapped[str | None] = mapped_column(String)
401
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
402
    finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
403
 
404
 
405
class SensorAsset(Base):
406
    """A real, publicly-sourced fixed asset: ALPR/traffic/surveillance camera, weather station,
407
    radar site, seismic station, stream gauge, air-quality monitor, etc. Sourced from public
408
    APIs (OSM/DeFlock, NWS, USGS, ...) - every row is real and carries its provenance."""
409
    __tablename__ = "sensor_assets"
410
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
411
    kind: Mapped[str] = mapped_column(String, index=True)
412
    category: Mapped[str] = mapped_column(String, index=True)
413
    source: Mapped[str] = mapped_column(String, index=True)
414
    external_id: Mapped[str | None] = mapped_column(String, index=True)
415
    name: Mapped[str | None] = mapped_column(String)
416
    operator: Mapped[str | None] = mapped_column(String, index=True)
417
    lat: Mapped[float | None] = mapped_column(Float)
418
    lon: Mapped[float | None] = mapped_column(Float)
419
    state: Mapped[str | None] = mapped_column(String, index=True)
420
    attributes: Mapped[dict] = mapped_column(JSON, default=dict)
421
    last_seen: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
422
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
423
    __table_args__ = (Index("ix_assets_kind_state", "kind", "state"),
424
                      Index("ix_assets_latlon", "lat", "lon"))
425
 
426
 
427
class LiveEntity(Base):
428
    """Latest snapshot of a real moving public entity (aircraft via ADS-B, satellite via TLE,
429
    vessel via AIS). Upserted by (source, identifier) as the live feed polls."""
430
    __tablename__ = "live_entities"
431
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
432
    kind: Mapped[str] = mapped_column(String, index=True)
433
    source: Mapped[str] = mapped_column(String, index=True)
434
    identifier: Mapped[str] = mapped_column(String, index=True)
435
    label: Mapped[str | None] = mapped_column(String)
436
    lat: Mapped[float | None] = mapped_column(Float)
437
    lon: Mapped[float | None] = mapped_column(Float)
438
    altitude_m: Mapped[float | None] = mapped_column(Float)
439
    heading: Mapped[float | None] = mapped_column(Float)
440
    velocity: Mapped[float | None] = mapped_column(Float)
441
    origin_country: Mapped[str | None] = mapped_column(String, index=True)
442
    attributes: Mapped[dict] = mapped_column(JSON, default=dict)
443
    observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
444
    __table_args__ = (UniqueConstraint("source", "identifier", name="uq_entity_source_ident"),)
445
 
446
 
447
class EntityPosition(Base):
448
    """Append-only position history for moving entities - builds the track/trail for any entity."""
449
    __tablename__ = "entity_positions"
450
    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
451
    identifier: Mapped[str] = mapped_column(String, index=True)
452
    kind: Mapped[str] = mapped_column(String)
453
    lat: Mapped[float] = mapped_column(Float)
454
    lon: Mapped[float] = mapped_column(Float)
455
    altitude_m: Mapped[float | None] = mapped_column(Float)
456
    observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
457
    __table_args__ = (Index("ix_pos_ident_ts", "identifier", "observed_at"),)
458
 
459
 
460
class Report(Base):
461
    __tablename__ = "reports"
462
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
463
    investigation_id: Mapped[str] = mapped_column(ForeignKey("investigations.id"))
464
    title: Mapped[str] = mapped_column(String)
465
    format: Mapped[str] = mapped_column(String, default="markdown")
466
    sections: Mapped[dict] = mapped_column(JSON, default=dict)
467
    rendered_ref: Mapped[str | None] = mapped_column(String)
468
    created_by: Mapped[str | None] = mapped_column(String)
469
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
470
 
471
 
472
class Export(Base):
473
    __tablename__ = "exports"
474
    id: Mapped[str] = mapped_column(String, primary_key=True, default=_uuid)
475
    investigation_id: Mapped[str | None] = mapped_column(ForeignKey("investigations.id"))
476
    kind: Mapped[str] = mapped_column(String)
477
    format: Mapped[str] = mapped_column(String)
478
    filters: Mapped[dict] = mapped_column(JSON, default=dict)
479
    file_ref: Mapped[str | None] = mapped_column(String)
480
    created_by: Mapped[str | None] = mapped_column(String)
481
    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())