The Gateway persists all of its state in a single PostgreSQL database, accessed through Flask-SQLAlchemy. Every model inherits db.Model from the global db instance defined in src/connector/db_connection.py, and the schema is versioned by Alembic migrations under migrations/versions/. A second, parallel set of isaac_sim_* objects has no ORM model (so db.create_all() never creates them) and is defined entirely inside one migration — those are covered on their own page. This page is the map: the entity-relationship overview, a per-table column inventory, the foreign-key cascade matrix, and the handful of schema gotchas a future editor must not “fix” without understanding them.
This page describes structure. For how the schema is created and evolved (the DBConnector, the postgresql:// URI, the Alembic chain, and the APP_ENVIRONMENT=dev db.create_all() shortcut) see Migrations, DB Connection & Dev Mode. For the ORM→MAVLink translation of waypoints and fences see Mission & Geofence MAVLink Format. For the raw-SQL Isaac Sim tables see Isaac Sim Tables (Raw SQL, no ORM).

Entity-relationship overview

user is the root of nearly every ownership chain. Almost everything a user creates — drones, missions, waypoints, flights, media, geofences, calendar events — carries a user_id foreign key that cascades on user delete. The one deliberate exception is billing (subscription, payment), which uses the default RESTRICT so a user with financial history cannot be silently deleted. Note the two relationships between MISSION_EXECUTION and ASSET: an execution captures many assets (asset.execution_id), and an execution optionally points to one log asset (mission_execution.log_asset_id, the ArduPilot .bin).

Table inventory

Thirteen ORM-backed tables plus four raw-SQL Isaac Sim tables.
TableModel filePurpose
usersrc/models/user.pyAccount owner (email/bcrypt password, VPN ip, level, activation token)
dronesrc/models/drone.pyFleet vehicle (physical or SITL), owns rosbridge address and video-room details
missionsrc/models/mission.pyRoute-template header (name, unique per user)
mission_pointsrc/models/mission_point.pyOrdered waypoint: semantic type and MAVLink fields
mission_executionsrc/models/mission_execution.pyRealized flight record + ~40 log-analysis metric columns
assetsrc/models/asset.pyS3-backed media (video/image/logs)
user_drone_accesssrc/models/user_drone_access.pyJoin table for shared drone access
geofencesrc/models/geofence.pyInclusion/exclusion polygon or circle
geofence_pointsrc/models/geofence_point.pyFence vertex/center with MAVLink fence command
calendar_eventsrc/models/calendar_event.pyScheduled mission (with RRULE recurrence)
calendar_event_occurrencesrc/models/calendar_event_occurrence.pySparse per-occurrence status exceptions
subscriptionsrc/models/subscription.pyStripe subscription, 1:1 with user
paymentsrc/models/subscription.pyIndividual Stripe payment record
isaac_sim_* (4 tables)(no ORM model)Raw-SQL Isaac Sim scheduling → see its page
src/models/__init__.py is empty. Models register on db.metadata only as a side effect of being imported — the services (mission_service, drone_service, asset_service, …) import their models, and app.py imports all services. A new model that no loaded module imports will be invisible to both Alembic autogenerate and db.create_all(). Always import a new model somewhere in the startup path.

Column reference

usersrc/models/user.py
ColumnTypeConstraints
idIntegerPK
usernameString(80)unique, nullable (was NOT NULL in the initial migration, later relaxed)
passwordString(255)NOT NULL (bcrypt hash)
ipString(100)unique, nullable — the user’s WireGuard VPN address
is_activeBooleanNOT NULL, default false
levelEnum userlevel(admin, customer)nullable
activation_tokenString(10)unique, nullable
emailString(100)unique, NOT NULL
report_extra_emailsTextnullable — comma-separated CSV; get_report_recipients() splits it
get_report_recipients() returns the user’s email first, then the deduplicated extras — used by the report mailer.dronesrc/models/drone.py
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK user.id CASCADE, NOT NULL, indexed (ix_drone_user_id)
macString(100)unique, NOT NULL — deprecated but still required (see gotchas)
nameString(100)NOT NULL
ipString(100)unique, NOT NULL, indexed (ix_drone_ip)
portIntegerNOT NULL — rosbridge port (9090 physical; 9090+n for SITL)
mission_idIntegerFK mission.id, no ondelete, nullable — last-uploaded mission
typeEnum dronetype(physical, sitl)nullable at DB level (see gotchas)
vehicle_typeEnum dronevehicletype(rover, copter)default rover, nullable at DB level
video_room_idIntegerunique — Janus room id
video_room_passwordString(100)
video_room_tokenString(300)
activation_tokenString(10)unique, nullable
The video_room_* fields are pushed to the drone over rosbridge — see Janus Video Rooms. SITL lifecycle populates ip/port — see SITL Drone Lifecycle.user_drone_accesssrc/models/user_drone_access.py
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK user.id CASCADE, NOT NULL
drone_idIntegerFK drone.id CASCADE, NOT NULL
Unique constraint uq_user_drone_access(user_id, drone_id). This is the share-grant table; the User VPN writes iptables rules from it so only owning/granted users can reach a drone — see VPN IP Authentication & Jumphost Routing.
missionsrc/models/mission.py
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK user.id CASCADE, NOT NULL
nameString(255)NOT NULL
Unique constraint uq_user_name(user_id, name). One-to-many mission_points.mission_pointsrc/models/mission_point.py
ColumnTypeConstraints
idIntegerPK
mission_idIntegerFK mission.id CASCADE, NOT NULL
user_idIntegerFK user.id CASCADE, NOT NULL
lat, lng, altitudeFloatNOT NULL
typeEnum mission_point_types(base, fly, safepoint)NOT NULL
labelString(255)nullable
sequenceIntegerNOT NULL — ordering key
frameIntegerdefault 3 (GLOBAL_RELATIVE_ALT)
commandIntegerdefault 16 (WAYPOINT); 22=TAKEOFF, 21=LAND, 20=RTL
is_currentBooleandefault false
autocontinueBooleandefault true
param1param4Floatdefault 0.0
Each row carries both a semantic type (used by the UI) and the full MAVLink command set. to_mavlink_waypoint() maps latx_lat, lngy_long, altitudez_alt. The upload-to-drone semantics (including the auto-prepended TAKEOFF) live in Mission & Geofence MAVLink Format and DroneControlService.
mission_executionsrc/models/mission_execution.pyThe flight record. Created (status in_progress) when a drone arms/flies; mission_id is NULL for manual flights. After landing, LogAnalysisService parses the ArduPilot .bin (referenced by log_asset_id) and back-fills ~40 metric columns.
GroupColumns
Keysid PK; user_id (CASCADE, idx); drone_id (CASCADE, idx); mission_id (SET NULL, idx); log_asset_id FK asset.id (SET NULL)
Timingstarted_at (tz, NOT NULL, idx); ended_at (tz); duration_seconds
Statusstatus Enum executionstatus(in_progress, completed, aborted, error), NOT NULL default in_progress, idx
Reportreport_sent_at (tz); report_pending (NOT NULL default false)
Archivearchive_s3_key; archive_generated_at (tz)
Flight statslog_flight_time_seconds; total_distance_meters; max_altitude_meters; avg_speed_ms; max_speed_ms; log_analyzed_at
Batterybattery_start_voltage, battery_min_voltage, battery_end_voltage, battery_energy_wh, battery_remaining_pct
GPSgps_avg_satellites, gps_min_satellites, gps_avg_hdop, gps_fix_type, gps_fix_type_name
Efficiencyhover_throttle_pct
Vibrationvibe_{x,y,z}_avg, vibe_{x,y,z}_max, clip_0, clip_1, clip_2
Errors (JSON)error_count, error_events
Modes (JSON)mode_change_count, failsafe_count, mode_changes
Motors (JSON)motor_balance_avg, motor_balance_max, motor_balance_min, motor_count
Timestampsnotes; created (tz); modified (tz, onupdate)
Indexed on user_id, drone_id, mission_id, started_at, status. The metric semantics and reporting flow are documented in Executions, Log Analysis & Reports.assetsrc/models/asset.py
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK user.id CASCADE, NOT NULL, idx (ix_asset_user_id)
drone_idIntegerFK drone.id CASCADE, NOT NULL, idx (ix_asset_drone_id)
execution_idIntegerFK mission_execution.id SET NULL, nullable, idx
file_nameString(255)NOT NULL
s3_keyString(1024)nullable
checksumString(64)nullable, idx (ix_asset_checksum)
mime_typeString(255)nullable
content_lengthIntegerdefault 0
asset_typeEnum assettype(video, image, logs)nullable at DB level
statusEnum assetstatus(pending, ready, error)default 'pending' (see gotchas)
upload_idString(255)nullable — multipart upload id
createdDateTime(timezone=True)default datetime.now
modifiedDateTime (naive)default datetime.now, onupdate datetime.now
Asset storage/HLS/archive mechanics are in S3 Assets, HLS Video & Execution Archives.
geofencesrc/models/geofence.py
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK user.id CASCADE, NOT NULL, idx
nameString(255)NOT NULL
typeEnum geofence_types(polygon, circle)NOT NULL
fence_typeEnum fence_types(inclusion, exclusion)NOT NULL, default exclusion
enabledBooleanNOT NULL, default true
Unique constraint uq_geofence_user_name(user_id, name). Points cascade via ORM delete-orphan.geofence_pointsrc/models/geofence_point.py
ColumnTypeConstraints
idIntegerPK
geofence_idIntegerFK geofence.id CASCADE, NOT NULL, idx
user_idIntegerFK user.id CASCADE, NOT NULL, idx
lat, lngFloatNOT NULL
sequenceIntegerNOT NULL
frameIntegerdefault 3
commandIntegerNOT NULL — MAVLink fence command 5001–5004
param1param4Floatdefault 0.0
command encodes the fence type: 5001 POLY_INCLUSION, 5002 POLY_EXCLUSION, 5003 CIRCLE_INCLUSION, 5004 CIRCLE_EXCLUSION. param1 is the polygon vertex count or the circle radius (m). to_mavlink_fence_item() forces z_alt = 0.0 (2D fences). Full mapping in Mission & Geofence MAVLink Format.
calendar_eventsrc/models/calendar_event.py
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK user.id CASCADE, NOT NULL, idx
titleString(255)NOT NULL
descriptionTextnullable
scheduled_timeDateTime(timezone=True)NOT NULL, idx
statusEnum eventstatus(scheduled, completed, cancelled)NOT NULL, default scheduled, idx
drone_idIntegerFK drone.id SET NULL, nullable, idx
mission_idIntegerFK mission.id SET NULL, nullable
execution_idIntegerFK mission_execution.id SET NULL, nullable
recurrence_ruleString(255)nullable — an iCal RRULE
created_at, updated_atDateTime(timezone=True)NOT NULL
Unique constraint uq_user_event_title_time(user_id, title, scheduled_time).calendar_event_occurrencesrc/models/calendar_event_occurrence.pyA sparse exceptions table: a recurring event’s occurrences are virtual and default to scheduled; only occurrences whose status deviates get a row here.
ColumnTypeConstraints
idIntegerPK
event_idIntegerFK calendar_event.id CASCADE, NOT NULL, idx
occurrence_timeDateTime(timezone=True)NOT NULL, idx
statusEnum eventstatusNOT NULL, default scheduled
execution_idIntegerFK mission_execution.id SET NULL, nullable, idx
created_at, updated_atDateTime(timezone=True)NOT NULL
Unique constraint uq_event_occurrence_time(event_id, occurrence_time). Calendar behavior is documented in Users, IP Allocation, Calendar & Isaac Sim.
subscriptionsrc/models/subscription.py (pricing: €120/vehicle/year)
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK user.id (no ondelete), NOT NULL, unique (one sub per user)
stripe_customer_idString(255)unique, nullable
stripe_subscription_idString(255)unique, nullable
statusEnum subscriptionstatus(active, canceled, past_due, incomplete, trialing, unpaid)NOT NULL, default incomplete
vehicle_countIntegerNOT NULL, default 0
current_period_start / current_period_endDateTime (naive)nullable
created_at, updated_at, canceled_atDateTime(timezone=True)
paymentsrc/models/subscription.py
ColumnTypeConstraints
idIntegerPK
user_idIntegerFK user.id (no ondelete), NOT NULL
subscription_idIntegerFK subscription.id (no ondelete), nullable
stripe_payment_intent_idString(255)unique, nullable
stripe_invoice_idString(255)unique, nullable
amountIntegerNOT NULL — stored in cents (€120.00 = 12000)
currencyString(3)NOT NULL, default eur
statusString(50)NOT NULL — plain string (succeeded/failed/pending)
descriptionString(500)nullable
vehicle_countIntegerNOT NULL, default 1
created_atDateTime(timezone=True)NOT NULL
Billing logic and vehicle-limit enforcement live in Stripe Billing & Vehicle Limits.

Cascade rules

Ownership tables cascade from user; report/asset links soften to SET NULL so history survives the deletion of the target; billing intentionally does not cascade.
Foreign keyReferencesON DELETE
drone.user_iduser.idCASCADE
drone.mission_idmission.idnone (RESTRICT) ⚠️
mission.user_iduser.idCASCADE
mission_point.mission_idmission.idCASCADE
mission_point.user_iduser.idCASCADE
mission_execution.user_iduser.idCASCADE
mission_execution.drone_iddrone.idCASCADE
mission_execution.mission_idmission.idSET NULL
mission_execution.log_asset_idasset.idSET NULL
asset.user_id / asset.drone_iduser.id / drone.idCASCADE
asset.execution_idmission_execution.idSET NULL
user_drone_access.user_id / .drone_iduser.id / drone.idCASCADE
geofence.user_iduser.idCASCADE
geofence_point.geofence_id / .user_idgeofence.id / user.idCASCADE (+ ORM delete-orphan)
calendar_event.user_iduser.idCASCADE
calendar_event.drone_id / .mission_id / .execution_iddrone/mission/executionSET NULL
calendar_event_occurrence.event_idcalendar_event.idCASCADE (+ delete-orphan)
calendar_event_occurrence.execution_idmission_execution.idSET NULL
subscription.user_iduser.idnone (RESTRICT) ⚠️
payment.user_id / .subscription_iduser.id / subscription.idnone (RESTRICT) ⚠️
drone.mission_id has no ON DELETE rule — deleting a mission that any drone still references would RESTRICT. mission_service.delete() (src/service/mission_service.py:83) compensates: it runs Drone.query.filter(Drone.mission_id == mission_id).update({"mission_id": None}) and deletes the mission’s points before deleting the Mission. Any refactor that changes cascade behavior must preserve this manual cleanup (or add ON DELETE SET NULL).

Schema gotchas

These are load-bearing quirks. Preserve them across refactors unless you deliberately migrate the data.
Drone.type is declared db.Column(Enum(DroneType, nullable=False)) (src/models/drone.py:50). The nullable=False is passed to the Enum type, not the Column — so at the DB level these columns are actually nullable. The initial migration confirms it: drone.type is created nullable=True (migrations/versions/cd0db35fec77_initial_migration.py:44). The same pattern affects Drone.vehicle_type and Asset.asset_type. Do not assume NOT NULL on these columns.
Enum columns are backed by global PostgreSQL types: dronetype, dronevehicletype, mission_point_types, geofence_types, fence_types, executionstatus, eventstatus, subscriptionstatus, assettype, assetstatus, userlevel.EventStatus and SubscriptionStatus are declared with values_callable=lambda x: [e.value for e in x], so PostgreSQL stores the lowercase value ("scheduled"). The others (DroneType, ExecutionStatus, AssetType, …) store the member name. This matters when you write raw SQL or add new members — check the declaration before assuming what string is on disk.
Asset.status is an Enum(AssetStatus) column but its default is AssetStatus.pending.value — the string "pending" — not the enum member (src/models/asset.py:52). It works only because the member name equals its value. Every other enum default in the codebase passes the member itself; keep the distinction in mind when copying the pattern.
Most audit timestamps are timezone-aware (DateTime(timezone=True) + datetime.now(timezone.utc)): mission_execution, calendar_event, subscription.created_at/updated_at. But several are naive:
  • asset.modified — plain db.DateTime with datetime.now (while asset.created is tz-aware).
  • subscription.current_period_start / current_period_end — naive db.DateTime.
Comparing or serializing these against tz-aware values can surprise you. Normalize explicitly.
subscription.user_id, payment.user_id, and payment.subscription_id have no ondelete, so they default to RESTRICT. Because most user-owned tables CASCADE, a naive DELETE FROM user succeeds for drones/missions/assets but fails if the user has a subscription or payment. Delete billing rows first, or add explicit cascade rules if that is the intent.
Drone.mac is annotated TODO:FIXME: DEPRECATE! - this is no longer used (src/models/drone.py:34) yet remains unique=True, nullable=False. Every drone insert must still supply a unique mac value — SITL creation, for example, sets ip == mac == container_name.
The isaac_sim_instances, isaac_sim_usage_tracking, isaac_sim_budget, and isaac_sim_config tables (built with op.create_table in migrations/versions/e85b13d42b08_add_isaac_sim_tables.py) — plus 3 views, 2 PL/pgSQL functions, and their triggers (raw op.execute SQL in the same migration) — have no ORM model. They are read/written with a raw psycopg2 cursor in src/service/instance_scheduler_service.py, bypassing the ORM entirely. The dev-only db.create_all() shortcut will not create them, so a dev database is incomplete for Isaac Sim. Full details on Isaac Sim Tables (Raw SQL, no ORM).
The Alembic chain is linear to a single head (r3m4n5o6p7q8), but revision IDs interleave random hashes (cd0db35fec77) with hand-authored sequential letters (i4d5e6f7g8h9r3m4n5o6p7q8), and down_revision values are not always alphabetical — trust down_revision, not filenames. The migration mechanics are on Migrations, DB Connection & Dev Mode.

Where to go next

Migrations & Connection

DBConnector, the postgresql:// URI, the Alembic chain, and the dev-mode create_all() shortcut.

Mission & Geofence MAVLink Format

How MissionPoint / GeofencePoint map to MAVLink waypoints and fence items.

Isaac Sim Tables (Raw SQL)

The four raw-SQL tables, views, functions and triggers with no ORM model.

Full Database Schema Reference

The platform-wide, cross-repo schema reference.