Skip to content

Operational Map implementation walkthrough

This page explains how the operational map works and the reasoning a reviewer should assess. Release findings and verification results are recorded separately in the repository-root OPERATIONAL_MAP_REVIEW.md.

Purpose and boundaries

The map is a view of Mission Plan deployment coordinates. It overlays those positions on locally uploaded raster MBTiles archives. It does not track a device through GPS, poll for position updates, or deploy configuration. Reloading the page refreshes its mission snapshot.

“Offline” means Nautobot and its local storage remain reachable while Internet access is unavailable. It does not mean a browser can operate after disconnecting from Nautobot. OpenLayers (10.11.0, BSD 2-Clause, vendored locally -- see static/nautobot_trax/openlayers/README.txt) renders the map; basemap tiles are served locally too. There is no external tile provider fallback in the map script.

Request flow

flowchart TD
    Upload[Atlas upload through UI or REST] --> Validate[Model validates MBTiles metadata]
    Validate --> Store[Private archive storage and MapTileSet metadata]
    Browser[Authenticated browser] --> Page[OperationalMapView]
    Page --> Permissions[Restrict missions, operations, and basemaps]
    Permissions --> JSON[Escaped JSON configuration in template]
    JSON --> OpenLayers[Local OpenLayers renderer]
    OpenLayers --> Tiles[Permission-checked XYZ tile endpoint]
    Tiles --> Available[Check archive availability]
    Available --> Cache[Versioned tile-byte cache]
    Cache -->|Miss| SQLite[Read archive using TMS row coordinates]
    Browser --> Download[Object-permission-checked archive download]
    Download --> Store

Archives have no generic media URL. UI downloads use the atlas view permission; REST downloads use the viewset's restricted object lookup and support API authentication. Both return attachments with a fixed binary content type.

Files and responsibilities

File Responsibility
models/map.py Defines MapTileSet, derived metadata, and upload validation/save behavior
map_storage.py Private filesystem storage, authorized file URLs, safe attachments, and commit-aware cleanup
utils.py Reads archive metadata and retrieves one tile using SQLite
forms.py Atlas UI forms and permission-restricted operation selection
api/serializers.py, api/views.py, api/urls.py Standard Nautobot REST management of atlas records/uploads
views.py Atlas UI, filtered mission data, basemap configuration, tile authorization and caching
urls.py, navigation.py Registers routes and permission-aware navigation links
templates/nautobot_trax/operational_map.html Displays filters, markers, labels, basemap selection, and zoom behavior
static/nautobot_trax/openlayers/ Locally served OpenLayers 10.11.0 assets
migrations/0009_*, 0010_*, 0011_* Creates the atlas model, records available zooms, and migrates legacy public archives to private storage
migrations/0012_* Adds optional operation basemap associations and a preferred map
tests/test_operational_map.py Metadata, uploads, permissions, filters, and cache regressions
tests/test_map_security.py Download authorization, hostile input limits, private storage, migration, cleanup, and demo-role regressions
tests/test_map_availability.py Storage loss/restoration, conditional responses, operation basemap selection, and deployment diagnostics
management/commands/check_map_storage.py Checks private-directory writes and referenced archive readability on the current instance
management/commands/cleanup_map_archives.py Age-guarded preview/removal of unreferenced private archives
tools/check_operational_map_browser.py Exercises the actual map script in local headless Chrome
tools/build_mbtiles_overviews.py Optional offline helper that creates overview imagery in a separate archive

Model and upload handling

MapTileSet stores a unique name, description, uploaded file, and activation flag. The raster format, attribution, zoom limits/list, and geographic bounds are extracted from the archive instead of accepting the browser's assertions.

clean() reads the file while it is still an upload. The helper copies the file to a temporary filesystem path because SQLite needs a path, and restores the upload stream's original position. A label/activation edit skips rescanning an unchanged committed archive. save() also validates a newly assigned upload so programmatic/API creation populates derived fields. Updating only the file extends update_fields to persist the derived fields and timestamp as well.

The parser accepts the MBTiles table/view interfaces, raster PNG/JPEG/WebP format names, and common MIME aliases. It queries actual zoom levels instead of trusting potentially stale minzoom/maxzoom metadata. Missing or invalid bounds are derived from coverage at the most detailed zoom. Vector PBF archives are rejected because the map's ol.source.XYZ raster tile layer cannot render vector data.

New uploads require an .mbtiles extension and receive UUID-based storage names. PrivateMapStorage rejects roots inside media/static storage, writes files with mode 0600, and creates directories with mode 0700. Its generic url() raises an error; the custom field supplies the authorized atlas download route instead. An administrator must also keep the directory out of proxy/static routes and mount persistent storage in every service that accesses it.

The local workspace Dockerfiles build from the workspace parent. Their adjacent Dockerfile.dockerignore files allow only the source projects they copy and exclude environment files, uploaded media, atlas files, and the MOBAC workspace. A .dockerignore inside the TRAX directory alone does not protect a parent build context; see Docker's build-context rules.

GitHub Actions uses development/Dockerfile.ci with the TRAX checkout as its build context. It installs companion apps from the constrained Poetry lockfile, so CI does not require neighboring source checkouts. Its adjacent ignore file excludes local credentials, archives, and caches. CI tests archive processing on Python 3.12 and 3.13; Python 3.10 retains core coverage and verifies that archive processing fails closed when SQLite resource limits are unavailable.

Uploaded schemas can contain executable SQLite views. _mbtiles_connection() therefore opens a read-only URI, disables trusted-schema features and memory mapping, enables query-only mode, and installs SQLite resource limits and a progress-handler deadline. Python 3.11 or later is required for setlimit(); older runtimes fail closed when processing an archive. Validation has a 30-second default query budget, tile reads 2 seconds. The progress handler interrupts SQLite VM work; this is not a hard wall-clock deadline on filesystem I/O or a process-wide memory limit. Ordinary indexed tables and views remain supported within the limits.

Validation bounds the temporary copy to 5 GiB by default, limits metadata entries and values, checks every coordinate against its zoom grid even when bounds are provided, and limits tile BLOBs to 16 MiB. SQL parameters bind requested coordinates. These checks establish structure, supported metadata, and bounded query work; they do not decode every tile to prove it is valid raster imagery. Web-server upload limits, filesystem quotas, and representative atlas timings remain deployment responsibilities.

Replacement/deletion schedules removal of unreferenced files with transaction.on_commit(). Rollback preserves the previous file; shared files remain while referenced. A new upload written before rollback can remain as a private orphan. cleanup_map_archives previews such files; --apply removes only unreferenced regular files older than the configured age guard.

Migration 0011 moves the entire legacy trax_map_tiles namespace, including old orphaned archives, out of media storage. It verifies SHA-256 copies before removing originals and preserves names so interrupted migrations can be retried. Missing referenced files or conflicting copies stop the upgrade. Run the migration with services stopped and both original media and private storage mounted. Its reverse intentionally does not restore public files. See the administrator instructions for configuration and recovery prerequisites.

Mission selection and permissions

OperationalMapView uses Nautobot's ObjectPermissionRequiredMixin. Its dispatch checks mission-view permission and replaces the view queryset with an object-restricted queryset. get_context_data() then excludes expired missions and records without both coordinates, and joins tenant/location to avoid a separate query for each marker.

The operation selector independently restricts its queryset to operations the user may view. Multiple selections use OR semantics and .distinct() to avoid duplicating a mission shared by two operations. Invalid or inaccessible selected operations produce form errors and an empty mission result. Clearing the filter includes all otherwise permitted plotted missions, including unassigned ones.

Active basemaps use their own restricted queryset. Permission to view a mission does not grant permission to view a basemap, and operation selection does not expand mission permissions. Navigation permissions hide links, while server checks remain the enforcement boundary.

Operation records can associate several Map Tile Sets and one preferred map. The preferred map is associated implicitly. _operational_basemaps() intersects these references with permitted active maps and checks each archive's SQLite header without scanning its tiles. Preferred ready maps sort first, other associated ready maps next, then remaining permitted ready maps. Name and UUID break ties independently of operation input order. The selector retains manual alternatives; a notice explains fallback when no associated map is available. Unavailable maps are reported only within the user's permitted atlas queryset. No stored availability flag can remain stale after restoring an archive.

Tenant/location names are included as part of an authorized mission's displayed summary. A deployment needing independent redaction of related-object names should explicitly define and test that policy.

Browser rendering and injection prevention

Django's json_script serializes the two configuration objects into inert JSON script elements. The renderer reads their text content rather than evaluating JavaScript generated from user data. Two distinct, both-safe mechanisms handle untrusted strings downstream, matched to how each one is rendered: permanent mission labels and the basemap switcher's option text are assigned via textContent, which never interprets its input as markup regardless of content. Popup fields and tile attribution are built as HTML strings (innerHTML) and therefore pass through escapeHtml() first -- the one path in this file where omitting it would be exploitable.

Circle markers render through an ol.layer.Vector (OpenLayers' own canvas renderer), avoiding individual SVG/DOM element cost per marker. Permanent labels still create one DOM element (an ol.Overlay) each; the implementation is not a clustered or paginated view for arbitrarily large mission inventories. Add performance tests with representative mission counts before promising scale.

The marker colors correspond to active/planned mission status. Expiration here means the stored expired status; a past end date alone does not remove a marker. Coordinates are already normalized/stored on MissionPlan; the browser does not implement a second MGRS conversion algorithm.

Offline zoom and viewport behavior

The first permitted active atlas is selected initially. The viewport prioritizes missions inside its coverage, then atlas coverage, then a default view. Missions outside coverage remain in the data without pulling the initial view away from available imagery. Switching a basemap updates both zoom limits and viewport.

The tile layer builds an OpenLayers TileGrid from the actual zoom_levels. Each source-grid index represents one exported XYZ resolution. A custom tileUrlFunction translates that index to the native XYZ zoom in the URL; the grid already computes the correct x/y coordinates and geographic extent. For older records without zoom_levels, the grid includes every level between min_zoom and max_zoom.

The source's zDirection: 1 chooses the coarser available resolution between exported levels. OpenLayers stretches that tile over its full native extent, including when the view zoom exceeds the archive maximum. Remapping a parent tile URL into a finer grid would repeat the entire image inside each child tile and misplace geography. Using native resolutions also avoids multiplying fine-tile requests for a regional overview. One zoom level finer can require four times as many tiles for the same area; four levels finer can require 256 times as many. See the upstream TileGrid API.

Two additional display zoom levels stretch existing imagery to separate nearby markers. They add no cartographic detail. Zooming out below the native minimum requires real overview tiles. The optional overview builder copies an archive, reduces each group of four children into a parent, respects TMS north/south orientation, and never overwrites the source or an existing destination.

tools/check_operational_map_browser.py checks native zoom URLs and samples the final rendered canvas at known geographic coordinates using a four-color tile. These regressions cover native levels, gaps between levels, zoom beyond the native maximum, legacy metadata, and a zero-only atlas. The imagery is local and synthetic; HTTP authentication and binary image serving have separate tests.

The view also respects OpenLayers' default projection/viewport constraints. On a large viewport the effective zoom-out floor can exceed the configured minZoom to keep the viewport within one world. This is independent of archive resolution selection; see the upstream View constraints.

Tile endpoint and cache

Wheel input uses OpenLayers' own ol.interaction.MouseWheelZoom, configured with constrainResolution: false (continuous, not snapped to whole levels) and useAnchor: true (zooms toward the pointer, not the map center). It accumulates a fractional target, eases toward it, and anchors to the pointer natively -- no custom animation code involved. The View's own constrainResolution: false keeps gestures fractional; the Zoom control (the on-screen +/- buttons) steps by a whole level on its own, independent of the wheel interaction's configuration.

Interrupt handling (a drag, a basemap switch, or page unload interrupting an in-flight wheel gesture) and tile-loading behavior during a gesture are OpenLayers' own internal responsibility, not code this project maintains, so tools/check_operational_map_browser.py does not re-verify them. It checks what this project's own code actually owns: that the gesture produces continuous, pointer-anchored zoom, that zoom buttons remain whole-level, that native tiles remain geographically aligned across display zooms, that mission labels/popups never interpret untrusted names as HTML, and that coverage/basemap-switching notices update correctly. The virtual-clock tests schedule frame callbacks deterministically; they verify interaction and request behavior, not a workstation's physical frame rate.

The north_carolina and austria dictionaries in the browser checks are synthetic mission fixtures. [north_carolina] passes a one-mission list to a case; it does not configure production coverage or an AOR. US/European bounds exercise both inside-coverage and outside-coverage behavior. Gesture tests take their starting coordinates from the fixture instead of a separate hard-coded location.

MapTileView applies the atlas permission mixin before loading an object, cache entry, or conditional response. It rejects invalid XYZ values before computing large powers, then rejects unavailable zoom levels. The SQLite query uses bound parameters for coordinates. MBTiles uses bottom-origin TMS rows, so a request with top-origin XYZ row y is translated to 2**z - 1 - y.

The server caches tile bytes for one day and missing-tile sentinels for ten minutes. Cache keys include atlas ID, last-updated timestamp, and tile coordinates. Tile URLs also include a version parameter, so a successful file replacement gets fresh URLs. A missing tile returns 404. Successful responses include an ETag and private, max-age=0, must-revalidate; conditional requests can return 304 only after object permission, live archive readability, and tile-existence checks. Thus new requests after permission revocation are denied even for a cached tile. Already downloaded copies cannot be recalled. Archive downloads use private, no-store, attachment disposition, a generated .mbtiles download name, application/octet-stream, and nosniff.

Missing/unreadable storage, invalid archive headers, and expected SQLite errors produce a controlled 503 with private, no-store and Retry-After: 30. SQLite query failures also log an atlas-ID warning. An ordinary missing tile remains a 404. Availability is checked before serving bytes cached in Redis or validating a browser's saved representation, so caches cannot conceal a lost mount. Unexpected programming errors are not silently swallowed. Large atlases still need a cache capacity policy; permissions and TTL alone do not bound Redis memory consumption.

Directly changing an archive on disk bypasses timestamp-based invalidation. Use supported model/form/API updates. File lookup currently requires local filesystem storage because the view accesses FileField.path; remote object storage requires a separate retrieval/local-cache design.

Review questions and expected evidence

Question Evidence to request
Are mission and operation constraints independent? Restricted-user and inaccessible-filter tests
Can an atlas be read through a different URL? Direct raw-media/download authorization regression tests
Can an uploaded filename become active content? Reject/safe-download tests for HTML-named SQLite files
What stops an expensive schema or huge BLOB? SQLite budget/limit tests and representative large-atlas timings
Do file changes update metadata and caches? UI/API replacement tests and new-version ETag checks
What happens after storage loss or deletion? Expected error response, cleanup/retention tests, restore procedure
Can zoom changes multiply requests unexpectedly? Browser scenarios for sparse, zero-only, and detail-only atlases
Is the installed package self-contained? Fresh-wheel install, migrations, collectstatic, and license/content inspection
Is the release safe outside the demo? Explicit opt-in for demo account creation and documented role provisioning

Claims to keep precise

The design supports local raster basemaps, stored mission positions, and server-side object-restricted map, tile, and archive-download views. Query limits reduce hostile-schema exposure; they do not establish complete sandboxing of SQLite or a hard process memory budget. A passing map suite does not establish live position tracking, recall of downloaded imagery, large-inventory performance, or compatibility with every version/backend in broad dependency ranges.

The fixed-password operator/operator login is restricted to explicit demo provisioning. enable_demo_user defaults to False; the supplied development configuration enables it. setup_operator_user refreshes the demo operator's existing mission/operation access and MapTileSet view/add/change/delete permissions without resetting an existing password unless --reset is supplied.