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.
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.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
user, drone, user_drone_access — identity & fleet
user, drone, user_drone_access — identity & fleet
user — src/models/user.pyget_report_recipients() returns the user’s email first, then the deduplicated extras — used by the report mailer.drone — src/models/drone.pyvideo_room_* fields are pushed to the drone over rosbridge — see Janus Video Rooms. SITL lifecycle populates ip/port — see SITL Drone Lifecycle.user_drone_access — src/models/user_drone_access.pyuq_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.mission, mission_point — route templates
mission, mission_point — route templates
mission — src/models/mission.pyuq_user_name(user_id, name). One-to-many mission_points.mission_point — src/models/mission_point.pytype (used by the UI) and the full MAVLink command set. to_mavlink_waypoint() maps lat→x_lat, lng→y_long, altitude→z_alt. The upload-to-drone semantics (including the auto-prepended TAKEOFF) live in Mission & Geofence MAVLink Format and DroneControlService.mission_execution, asset — flights & media
mission_execution, asset — flights & media
mission_execution — src/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.user_id, drone_id, mission_id, started_at, status. The metric semantics and reporting flow are documented in Executions, Log Analysis & Reports.asset — src/models/asset.pygeofence, geofence_point — no-fly / keep-in zones
geofence, geofence_point — no-fly / keep-in zones
geofence — src/models/geofence.pyuq_geofence_user_name(user_id, name). Points cascade via ORM delete-orphan.geofence_point — src/models/geofence_point.pycommand 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_event, calendar_event_occurrence — scheduling
calendar_event, calendar_event_occurrence — scheduling
calendar_event — src/models/calendar_event.pyuq_user_event_title_time(user_id, title, scheduled_time).calendar_event_occurrence — src/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.uq_event_occurrence_time(event_id, occurrence_time). Calendar behavior is documented in Users, IP Allocation, Calendar & Isaac Sim.subscription, payment — Stripe billing
subscription, payment — Stripe billing
subscription — src/models/subscription.py (pricing: €120/vehicle/year)payment — src/models/subscription.pyCascade rules
Ownership tables cascade fromuser; report/asset links soften to SET NULL so history survives the deletion of the target; billing intentionally does not cascade.
Schema gotchas
These are load-bearing quirks. Preserve them across refactors unless you deliberately migrate the data.Enum-nullability trap: drone.type / vehicle_type / asset_type are NULLABLE
Enum-nullability trap: drone.type / vehicle_type / asset_type are NULLABLE
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 storage: NAME vs VALUE (values_callable)
Enum storage: NAME vs VALUE (values_callable)
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 default is a raw string, not an enum member
Asset.status default is a raw string, not an enum member
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.Timezone inconsistency (naive vs aware timestamps)
Timezone inconsistency (naive vs aware timestamps)
DateTime(timezone=True) + datetime.now(timezone.utc)): mission_execution, calendar_event, subscription.created_at/updated_at. But several are naive:asset.modified— plaindb.DateTimewithdatetime.now(whileasset.createdis tz-aware).subscription.current_period_start/current_period_end— naivedb.DateTime.
Asymmetric billing cascade — deleting a user with billing FAILS
Asymmetric billing cascade — deleting a user with billing FAILS
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 deprecated but still NOT NULL + UNIQUE
drone.mac is deprecated but still NOT NULL + UNIQUE
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.Isaac Sim tables have no ORM model and skip db.create_all()
Isaac Sim tables have no ORM model and skip db.create_all()
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).
