diff --git a/app/models/inspection.py b/app/models/inspection.py index 8987d47..0789cc2 100644 --- a/app/models/inspection.py +++ b/app/models/inspection.py @@ -63,8 +63,10 @@ class Inspection(db.Model): status = db.Column(db.Enum('in_progress', 'completed', 'flagged'), default='in_progress') notes = db.Column(db.Text) # inspector free-text notes form_data = db.Column(db.JSON) # filled form field responses {field_id: value} - completed_at = db.Column(db.DateTime) - mobile_local_id = db.Column(db.String(64), nullable=True, index=True) # idempotency key for mobile submissions + completed_at = db.Column(db.DateTime) + mobile_local_id = db.Column(db.String(64), nullable=True, index=True) + submit_latitude = db.Column(db.Numeric(10, 7), nullable=True) + submit_longitude = db.Column(db.Numeric(10, 7), nullable=True) # ── Re-inspection / follow-up workflow ──────────────────────────────── parent_inspection_id = db.Column( diff --git a/app/routes/inspections.py b/app/routes/inspections.py index 9bdb68f..cbc6b22 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -533,6 +533,16 @@ def execute(inspection_id): inspection.status = 'completed' inspection.completed_at = now_eastern() + # Capture GPS coordinates submitted by the browser Geolocation API + try: + _lat = request.form.get('submit_lat', '').strip() + _lng = request.form.get('submit_lng', '').strip() + if _lat and _lng: + inspection.submit_latitude = float(_lat) + inspection.submit_longitude = float(_lng) + except (ValueError, TypeError): + pass + # Snapshot the current template schema so view() renders correctly # even if the template is later edited or deleted. _save_responses(inspection, responses, snapshot_schema=form_fields) diff --git a/app/templates/inspections/execute.html b/app/templates/inspections/execute.html index 8d85a85..4f4dcbd 100644 --- a/app/templates/inspections/execute.html +++ b/app/templates/inspections/execute.html @@ -238,6 +238,8 @@
+ + {# ── Header ── #}
@@ -889,6 +891,23 @@ document.getElementById('inspectionForm').addEventListener('submit', async funct } } } + // Capture GPS on final submit (not draft) — fire-and-forget with short timeout + if (_submittingAction === 'submit' && navigator.geolocation) { + await new Promise(resolve => { + const timer = setTimeout(resolve, 4000); // give up after 4 s + navigator.geolocation.getCurrentPosition( + pos => { + document.getElementById('submitLat').value = pos.coords.latitude; + document.getElementById('submitLng').value = pos.coords.longitude; + clearTimeout(timer); + resolve(); + }, + () => { clearTimeout(timer); resolve(); }, // permission denied / unavailable — proceed without + { timeout: 4000, maximumAge: 60000 } + ); + }); + } + // Native form submit — browser handles multipart encoding and follows the redirect form.submit(); } catch (err) { diff --git a/app/templates/inspections/view.html b/app/templates/inspections/view.html index 4f3b961..17e8355 100644 --- a/app/templates/inspections/view.html +++ b/app/templates/inspections/view.html @@ -510,6 +510,32 @@
+ {# ── Submission GPS (admin / director only) ──────────────────────────── #} + {% if current_user.role in ['admin', 'director'] and inspection.submit_latitude and inspection.submit_longitude %} + {% set _lat = inspection.submit_latitude | float %} + {% set _lng = inspection.submit_longitude | float %} + {% set _maps_key = config.get('GOOGLE_MAPS_API_KEY', '') %} +
+
+ Submission Location +
+ {% if _maps_key %} +
+ Submission location map +
+ {% endif %} +
+ {{ '%.6f' | format(_lat) }}, {{ '%.6f' | format(_lng) }} + + Open in Maps + +
+
+ {% endif %} + {# ── Build the filtered field set ────────────────────────────────────── Strategy: 1. Find all grid rows that contain at least one rated rating field. diff --git a/config.py b/config.py index 8cb36a4..841847d 100644 --- a/config.py +++ b/config.py @@ -51,6 +51,9 @@ class Config: # ── Digest email secret token (used to authenticate cron trigger) ──────── DIGEST_SECRET = os.environ.get('DIGEST_SECRET') + # ── Google Maps (used for GPS map on inspection view) ──────────────────── + GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY', '') + MAIL_SERVER = os.environ.get('MAIL_SERVER') MAIL_USERNAME = os.environ.get('MAIL_USERNAME') MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') diff --git a/migrations/versions/phase25_inspection_gps.py b/migrations/versions/phase25_inspection_gps.py new file mode 100644 index 0000000..0e78f36 --- /dev/null +++ b/migrations/versions/phase25_inspection_gps.py @@ -0,0 +1,36 @@ +"""phase25 — add GPS coordinates to inspections + +Adds submit_latitude and submit_longitude columns to the inspections table. +Populated at web submission time via browser Geolocation API. +Null for all existing inspections and mobile submissions (handled separately). +""" + +import sqlalchemy as sa +from alembic import op + +revision = 'phase25_inspection_gps' +down_revision = 'phase24_notify_defaults' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + has_lat = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + "WHERE TABLE_SCHEMA = DATABASE() " + " AND TABLE_NAME = 'inspections' " + " AND COLUMN_NAME = 'submit_latitude'" + )).scalar() + + if not has_lat: + op.add_column('inspections', + sa.Column('submit_latitude', sa.Numeric(10, 7), nullable=True)) + op.add_column('inspections', + sa.Column('submit_longitude', sa.Numeric(10, 7), nullable=True)) + + +def downgrade(): + op.drop_column('inspections', 'submit_longitude') + op.drop_column('inspections', 'submit_latitude')