Files
meshcore-bot/modules/web_viewer/tracking.py
T
agessaman e30cc8a498 refactor(web_viewer): move contact tracking and database admin into mixins
_get_tracking_data, _get_contact_detail and _calculate_distance move to modules/web_viewer/tracking.py (ContactTrackingMixin); the database statistics, table info and description, optimization and cache view move to modules/web_viewer/database_admin.py (DatabaseAdminMixin). All nine bodies are verbatim; BotDataViewer inherits both. tracking.py joins app.py on mypy's suppressed list, since the moved code carries the type errors mypy never checked in app.py. app.py keeps validate_sql_identifier and humanize_span imported so those module attributes still resolve. No test patched anything these methods use. app.py drops to about 5,800 lines, from 9,095 at the start of the refactor.
2026-10-01 15:30:48 -07:00

632 lines
34 KiB
Python

"""Contact tracking data for the web viewer: the contacts table, contact detail and distances."""
from __future__ import annotations
from typing import Any
class ContactTrackingMixin:
"""Mixed into BotDataViewer."""
_bucket_hop_chunks: Any
_compute_path_encoding_badge: Any
_db_connection: Any
_get_cached_contact_multibyte_hop_chunks: Any
config: Any
logger: Any
def _get_tracking_data(
self,
since='30d',
include_detail=False,
page: int | None = None,
page_size: int | None = None,
search: str = '',
path_bytes: str = '',
device_role: str = '',
hop_filter: str = '',
location_filter: str = '',
starred: str = '',
sort: str = 'last_seen',
direction: str = 'desc',
):
"""Get contact tracking data. since: 24h, 7d, 30d, 90d, or all (heard in that window).
include_detail=False (the interactive /api/contacts list) omits per-contact ``all_paths``
and ``raw_advert_data`` to keep the payload small; the UI fetches those on demand via
/api/contact-detail. The interactive route supplies ``page`` and ``page_size`` so path
enrichment is limited to visible contacts. include_detail=True (the export endpoint)
keeps the legacy full-result behavior and full fields.
"""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
# Get bot location from config
bot_lat = self.config.getfloat('Bot', 'bot_latitude', fallback=None)
bot_lon = self.config.getfloat('Bot', 'bot_longitude', fallback=None)
# Filter by last_heard (default: last 30 days). last_heard is stored as ISO-text
# datetime in LOCAL time (e.g. '2026-06-16 09:03:49.606966', written by datetime.now()),
# so the cutoff must also be local: datetime('now', 'localtime', ...). Using bare
# datetime('now', ...) computes the cutoff in UTC and shaves the local UTC offset off
# the window (e.g. a "24h" filter only returns ~17h of data in US/Pacific).
datetime_offsets = {
'24h': "'-24 hours'",
'7d': "'-7 days'",
'30d': "'-30 days'",
'90d': "'-90 days'",
}
where_parts = []
where_params: list[Any] = []
# A node can have more than one observed advert path. Treat its byte class as
# the widest path encoding seen for it, with the contact's current out-path as a
# fallback for databases that have not retained an observed path yet. This gives
# the list one stable, sortable value instead of placing the same node in several
# byte buckets. Only count rows with a known 1/2/3 encoding so NULL/invalid
# observations do not collapse to "1-byte" and block the out-path fallback.
path_bytes_expression = """COALESCE((
SELECT MAX(op.bytes_per_hop)
FROM observed_paths op
WHERE op.public_key = c.public_key
AND op.packet_type = 'advert'
AND op.path_hex IS NOT NULL AND op.path_hex != ''
AND op.bytes_per_hop IN (1, 2, 3)
), CASE WHEN c.out_bytes_per_hop IN (1, 2, 3)
THEN c.out_bytes_per_hop ELSE 0 END)"""
if since in datetime_offsets:
where_parts.append(
f"c.last_heard >= datetime('now', 'localtime', {datetime_offsets[since]})"
)
search = (search or '').strip().lower()[:100]
if search and not include_detail:
# Match the former client-side behavior: public keys are prefix-only,
# while names, roles, device types, and locations match anywhere.
escaped = search.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
where_parts.append(
"("
"LOWER(COALESCE(c.public_key, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.name, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.role, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.device_type, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.city, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.state, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.country, '')) LIKE ? ESCAPE '\\'"
")"
)
where_params.extend([f'{escaped}%'] + [f'%{escaped}%'] * 6)
path_bytes = str(path_bytes or '').strip()
if path_bytes in ('1', '2', '3'):
where_parts.append(f'{path_bytes_expression} = ?')
where_params.append(int(path_bytes))
elif path_bytes == 'unknown':
where_parts.append(f'{path_bytes_expression} = 0')
device_role = str(device_role or '').strip().lower()
if device_role in ('companion', 'repeater', 'roomserver', 'sensor'):
where_parts.append("LOWER(COALESCE(c.role, '')) = ?")
where_params.append(device_role)
elif device_role == 'other':
where_parts.append("LOWER(COALESCE(c.role, '')) NOT IN ('companion', 'repeater', 'roomserver', 'sensor')")
if hop_filter in ('0', '1', '2', '3'):
where_parts.append(
'COALESCE(c.hop_count, 0) = ?' if hop_filter == '0'
else 'COALESCE(c.hop_count, 0) >= ?'
)
where_params.append(int(hop_filter))
has_location_expression = (
"((c.city IS NOT NULL AND c.city != '') OR "
"(c.state IS NOT NULL AND c.state != '') OR "
"(c.country IS NOT NULL AND c.country != '') OR "
"(c.latitude IS NOT NULL AND c.longitude IS NOT NULL "
"AND c.latitude != 0 AND c.longitude != 0))"
)
if location_filter == 'known':
where_parts.append(has_location_expression)
elif location_filter == 'unknown':
where_parts.append(f'NOT {has_location_expression}')
if starred == 'yes':
where_parts.append('COALESCE(c.is_starred, 0) = 1')
elif starred == 'no':
where_parts.append('COALESCE(c.is_starred, 0) = 0')
where_clause = (' WHERE ' + ' AND '.join(where_parts)) if where_parts else ''
pagination = None
filtered_stats = None
if page is not None and page_size is not None and not include_detail:
page_size = max(1, min(200, int(page_size)))
page = max(1, int(page))
cursor.execute(
"""
SELECT
COUNT(*) AS total_items,
SUM(CASE WHEN c.last_heard >= datetime('now', 'localtime', '-24 hours') THEN 1 ELSE 0 END) AS contacts_24h,
SUM(CASE WHEN c.last_heard >= datetime('now', 'localtime', '-7 days') THEN 1 ELSE 0 END) AS contacts_7d,
SUM(CASE WHEN c.first_heard >= datetime('now', 'localtime', '-7 days')
AND LOWER(COALESCE(c.device_type, '')) LIKE '%companion%' THEN 1 ELSE 0 END) AS new_companions,
SUM(CASE WHEN c.first_heard >= datetime('now', 'localtime', '-7 days')
AND LOWER(COALESCE(c.device_type, '')) LIKE '%repeater%' THEN 1 ELSE 0 END) AS new_repeaters,
SUM(CASE WHEN c.first_heard >= datetime('now', 'localtime', '-7 days')
AND (LOWER(COALESCE(c.device_type, '')) LIKE '%room%'
OR LOWER(COALESCE(c.device_type, '')) LIKE '%server%') THEN 1 ELSE 0 END) AS new_room_servers
FROM complete_contact_tracking c
""" + where_clause,
tuple(where_params),
)
aggregate = cursor.fetchone()
total_items = int(aggregate['total_items'] or 0)
total_pages = max(1, (total_items + page_size - 1) // page_size)
page = min(page, total_pages)
pagination = {
'page': page,
'page_size': page_size,
'total_items': total_items,
'total_pages': total_pages,
'has_previous': page > 1,
'has_next': page < total_pages,
}
filtered_stats = {
'contacts_24h': int(aggregate['contacts_24h'] or 0),
'contacts_7d': int(aggregate['contacts_7d'] or 0),
'contacts_total': total_items,
'new_companions': int(aggregate['new_companions'] or 0),
'new_repeaters': int(aggregate['new_repeaters'] or 0),
'new_room_servers': int(aggregate['new_room_servers'] or 0),
}
# Fetch contacts directly (no join/group-by). The recent paths per contact are
# loaded in a second query below and assembled in Python. This avoids materializing
# a window-function CTE over all of observed_paths and grouping by every contact
# column (incl. the raw_advert_data blob) on every request. last_advert_timestamp is
# the per-contact value, so it matches the old MAX(...) over a single contact's rows.
detail_cols = "c.raw_advert_data," if include_detail else ""
sort_expressions = {
'username': "LOWER(COALESCE(c.name, ''))",
'device_type': "LOWER(COALESCE(c.device_type, ''))",
'location': (
"LOWER(CASE "
"WHEN c.city IS NOT NULL AND c.city != '' AND c.state IS NOT NULL AND c.state != '' "
"THEN c.city || ', ' || c.state "
"WHEN c.city IS NOT NULL AND c.city != '' THEN c.city "
"WHEN c.latitude IS NOT NULL AND c.longitude IS NOT NULL "
"AND c.latitude != 0 AND c.longitude != 0 THEN printf('%s, %s', c.latitude, c.longitude) "
"ELSE '' END)"
),
'snr': 'COALESCE(c.snr, 0)',
'hop_count': 'COALESCE(c.hop_count, 0)',
'path_bytes': path_bytes_expression,
'first_heard': "COALESCE(c.first_heard, '')",
'last_seen': "COALESCE(c.last_heard, '')",
'advert_count': 'COALESCE(c.advert_count, 0)',
}
sort = sort if sort in (*sort_expressions.keys(), 'distance') else 'last_seen'
direction = 'asc' if direction == 'asc' else 'desc'
if sort == 'distance':
if bot_lat is None or bot_lon is None:
sort_expression = '0'
else:
conn.create_function('contacts_distance_km', 2, lambda lat, lon: (
self._calculate_distance(bot_lat, bot_lon, lat, lon)
if lat is not None and lon is not None else 0
))
sort_expression = 'contacts_distance_km(c.latitude, c.longitude)'
else:
sort_expression = sort_expressions[sort]
query_params = list(where_params)
limit_clause = ''
if pagination is not None:
limit_clause = ' LIMIT ? OFFSET ?'
query_params.extend([page_size, (page - 1) * page_size])
cursor.execute("""
SELECT
c.public_key, c.name, c.role, c.device_type,
c.latitude, c.longitude, c.city, c.state, c.country,
c.snr, c.hop_count, c.first_heard, c.last_heard,
c.advert_count, c.is_currently_tracked,
""" + detail_cols + """
c.signal_strength,
c.is_starred, c.out_path, c.out_path_len, c.out_bytes_per_hop,
""" + path_bytes_expression + """ AS path_bytes_per_hop,
c.last_advert_timestamp as last_message
FROM complete_contact_tracking c
""" + where_clause + """
ORDER BY """ + sort_expression + f" {direction.upper()}, c.public_key ASC" + limit_clause,
tuple(query_params),
)
main_rows = cursor.fetchall()
paths_by_key = {}
path_rows = []
if main_rows:
path_params: list[Any] = []
page_key_clause = ''
if pagination is not None:
# The interactive list enriches only the visible page. At most 200 keys are
# supplied, staying comfortably below SQLite's parameter limit and turning
# the former all-history window scan into targeted index lookups.
page_keys = [row['public_key'] for row in main_rows]
placeholders = ','.join('?' for _ in page_keys)
page_key_clause = f' AND public_key IN ({placeholders})'
path_params.extend(page_keys)
cursor.execute("""
WITH recent_paths AS (
SELECT public_key, path_hex, path_length, bytes_per_hop,
observation_count, last_seen,
ROW_NUMBER() OVER (PARTITION BY public_key ORDER BY last_seen DESC) as rn
FROM observed_paths
WHERE packet_type = 'advert' AND public_key IS NOT NULL
""" + page_key_clause + """
)
SELECT public_key, path_hex, path_length, bytes_per_hop, observation_count, last_seen
FROM recent_paths WHERE rn <= 50
ORDER BY public_key, last_seen DESC
""", tuple(path_params))
path_rows = cursor.fetchall()
for prow in path_rows:
if not prow['path_hex']: # Skip empty paths
continue
bph = None
if prow['bytes_per_hop'] is not None:
try:
bph = int(prow['bytes_per_hop'])
if bph not in (1, 2, 3):
bph = 1
except (TypeError, ValueError):
bph = 1
paths_by_key.setdefault(prow['public_key'], []).append({
'path_hex': prow['path_hex'],
'path_length': int(prow['path_length']) if prow['path_length'] is not None else 0,
'bytes_per_hop': bph,
'observation_count': int(prow['observation_count']) if prow['observation_count'] is not None else 1,
'last_seen': prow['last_seen'] if prow['last_seen'] is not None else None
})
multibyte_hop_chunks = self._get_cached_contact_multibyte_hop_chunks(cursor)
chunk_buckets = self._bucket_hop_chunks(multibyte_hop_chunks)
tracking = []
for row in main_rows:
# Calculate distance if both bot and contact have coordinates
distance = None
if (bot_lat is not None and bot_lon is not None and
row['latitude'] is not None and row['longitude'] is not None):
distance = self._calculate_distance(bot_lat, bot_lon, row['latitude'], row['longitude'])
# Recent paths for this contact (grouped from the second query above). The full
# path objects are NOT sent in the list payload (they were ~70% of its size and
# are only used in the per-contact modal); the UI fetches them on demand via
# /api/contact-detail. The list only needs the count and the badge.
all_paths = paths_by_key.get(row['public_key'], [])
paths_count = len(all_paths)
# Preserve the legacy total_messages value: it was COUNT(*) over the LEFT-JOINed
# path rows, i.e. the number of paths, or 1 when a contact had no paths.
total_messages = max(1, paths_count)
path_encoding_badge = self._compute_path_encoding_badge(
row, all_paths, chunk_buckets
)
# The badge/tooltip decodes out_path (the "primary" path) using out_bytes_per_hop.
# The contact column can be stale (e.g. left at 1 while the primary path is a 3-byte
# path), which makes a multi-byte path render as twice/three-times as many 1-byte
# hops. Index the encoding on the primary observed path itself, which carries the
# authoritative bytes_per_hop, falling back to the contact column when unmatched.
out_path_val = row['out_path'] if row['out_path'] is not None else ''
out_bytes_per_hop_val = row['out_bytes_per_hop'] if row['out_bytes_per_hop'] is not None else None
if out_path_val:
primary_path = next((p for p in all_paths if p['path_hex'] == out_path_val), None)
if primary_path and primary_path.get('bytes_per_hop') in (1, 2, 3):
out_bytes_per_hop_val = primary_path['bytes_per_hop']
entry = {
'user_id': row['public_key'],
'username': row['name'],
'role': row['role'],
'device_type': row['device_type'],
'latitude': row['latitude'],
'longitude': row['longitude'],
'city': row['city'],
'state': row['state'],
'country': row['country'],
'snr': row['snr'],
'hop_count': row['hop_count'],
'first_heard': row['first_heard'],
'last_seen': row['last_heard'],
'advert_count': row['advert_count'],
'is_currently_tracked': row['is_currently_tracked'],
'signal_strength': row['signal_strength'],
'total_messages': total_messages,
'last_message': row['last_message'],
'distance': distance,
'is_starred': bool(row['is_starred'] if row['is_starred'] is not None else 0),
'out_path': out_path_val,
'out_path_len': row['out_path_len'] if row['out_path_len'] is not None else -1,
'out_bytes_per_hop': out_bytes_per_hop_val,
'path_bytes_per_hop': int(row['path_bytes_per_hop'] or 0),
'paths_count': paths_count,
'path_encoding_badge': path_encoding_badge,
}
if include_detail:
# Full fidelity for the export endpoint (size-tolerant, infrequent download).
raw_advert_data = row['raw_advert_data']
raw_advert_data_parsed = None
if raw_advert_data:
try:
import json
raw_advert_data_parsed = json.loads(raw_advert_data)
except Exception:
raw_advert_data_parsed = None
entry['all_paths'] = all_paths
entry['raw_advert_data'] = raw_advert_data
entry['raw_advert_data_parsed'] = raw_advert_data_parsed
tracking.append(entry)
# Get server statistics for daily tracking using direct database queries
server_stats = {}
try:
# Check if daily_stats table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='daily_stats'")
if cursor.fetchone():
# 24h: Last 24 hours of advertisements
cursor.execute("""
SELECT SUM(advert_count) FROM daily_stats
WHERE date >= date('now', 'localtime', '-1 day')
""")
server_stats['advertisements_24h'] = cursor.fetchone()[0] or 0
# 7d: Previous 6 days (excluding today)
cursor.execute("""
SELECT SUM(advert_count) FROM daily_stats
WHERE date >= date('now', 'localtime', '-7 days') AND date < date('now', 'localtime')
""")
server_stats['advertisements_7d'] = cursor.fetchone()[0] or 0
# All: Everything
cursor.execute("""
SELECT SUM(advert_count) FROM daily_stats
""")
server_stats['total_advertisements'] = cursor.fetchone()[0] or 0
# Nodes per day statistics
# Calculate today's unique nodes from complete_contact_tracking
# (last_heard in last 24 hours) since daily_stats might not have today's data yet
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM complete_contact_tracking
WHERE last_heard >= datetime('now', 'localtime', '-24 hours')
""")
server_stats['nodes_24h'] = cursor.fetchone()[0] or 0
# Get today's unique nodes by role for the stacked chart
cursor.execute("""
SELECT role, COUNT(DISTINCT public_key) as count
FROM complete_contact_tracking
WHERE last_heard >= datetime('now', 'localtime', '-24 hours')
AND role IS NOT NULL AND role != ''
GROUP BY role
""")
today_by_role = {}
for row in cursor.fetchall():
role = row[0].lower() if row[0] else 'unknown'
count = row[1]
today_by_role[role] = count
server_stats['nodes_24h_by_role'] = {
'companion': today_by_role.get('companion', 0),
'repeater': today_by_role.get('repeater', 0),
'roomserver': today_by_role.get('roomserver', 0),
'sensor': today_by_role.get('sensor', 0),
'other': sum(v for k, v in today_by_role.items() if k not in ['companion', 'repeater', 'roomserver', 'sensor'])
}
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', 'localtime', '-7 days') AND date < date('now', 'localtime')
""")
server_stats['nodes_7d'] = cursor.fetchone()[0] or 0
# Calculate day-over-day and period-over-period comparisons
# Today vs 7 days ago (single day comparison)
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date = date('now', 'localtime', '-7 days')
""")
result = cursor.fetchone()
server_stats['nodes_7d_ago'] = result[0] if result and result[0] else 0
# Last 7 days vs previous 7 days (days 8-14 ago)
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', 'localtime', '-14 days') AND date < date('now', 'localtime', '-7 days')
""")
result = cursor.fetchone()
server_stats['nodes_prev_7d'] = result[0] if result and result[0] else 0
# Last 30 days vs previous 30 days (days 31-60 ago)
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', 'localtime', '-60 days') AND date < date('now', 'localtime', '-30 days')
""")
result = cursor.fetchone()
server_stats['nodes_prev_30d'] = result[0] if result and result[0] else 0
# Also get current period totals for comparison
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', 'localtime', '-7 days')
""")
server_stats['nodes_7d'] = cursor.fetchone()[0] or 0
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
WHERE date >= date('now', 'localtime', '-30 days')
""")
server_stats['nodes_30d'] = cursor.fetchone()[0] or 0
cursor.execute("""
SELECT COUNT(DISTINCT public_key) FROM daily_stats
""")
server_stats['nodes_all'] = cursor.fetchone()[0] or 0
# Get daily unique node counts by role for the last 30 days for the stacked graph
# Join daily_stats with complete_contact_tracking to get role information
# This gives us accurate historical daily counts by role
cursor.execute("""
SELECT ds.date, c.role, COUNT(DISTINCT ds.public_key) as daily_count
FROM daily_stats ds
LEFT JOIN complete_contact_tracking c ON ds.public_key = c.public_key
WHERE ds.date >= date('now', 'localtime', '-30 days') AND ds.date <= date('now', 'localtime')
AND (c.role IS NOT NULL AND c.role != '')
GROUP BY ds.date, c.role
ORDER BY ds.date ASC, c.role ASC
""")
daily_data_by_role = cursor.fetchall()
# Organize data by date and role
daily_by_role = {}
for row in daily_data_by_role:
date_str = row[0]
role = (row[1] or 'unknown').lower()
count = row[2]
if date_str not in daily_by_role:
daily_by_role[date_str] = {}
daily_by_role[date_str][role] = count
# Convert to array format with all roles for each date
server_stats['daily_nodes_30d_by_role'] = []
for date_str in sorted(daily_by_role.keys()):
roles_data = daily_by_role[date_str]
server_stats['daily_nodes_30d_by_role'].append({
'date': date_str,
'companion': roles_data.get('companion', 0),
'repeater': roles_data.get('repeater', 0),
'roomserver': roles_data.get('roomserver', 0),
'sensor': roles_data.get('sensor', 0),
'other': sum(v for k, v in roles_data.items() if k not in ['companion', 'repeater', 'roomserver', 'sensor'])
})
# Also keep the total count for backward compatibility
cursor.execute("""
SELECT date, COUNT(DISTINCT public_key) as daily_count
FROM daily_stats
WHERE date >= date('now', 'localtime', '-30 days') AND date <= date('now', 'localtime')
GROUP BY date
ORDER BY date ASC
""")
daily_data = cursor.fetchall()
server_stats['daily_nodes_30d'] = [
{'date': row[0], 'count': row[1]}
for row in daily_data
]
except Exception as e:
self.logger.debug(f"Could not get server stats: {e}")
result = {
'tracking_data': tracking,
'server_stats': server_stats
}
if pagination is not None:
result['pagination'] = pagination
result['filtered_stats'] = filtered_stats
return result
except Exception as e:
self.logger.error(f"Error getting tracking data: {e}")
return {'error': str(e)}
def _get_contact_detail(self, public_key: str) -> dict:
"""Per-contact detail loaded on demand by the contacts UI modals.
Returns the recent advert paths (same shape as the old list ``all_paths``) and the raw
advertisement data. Both are excluded from the /api/contacts list payload — they were
~85% of its size and are only needed when a single contact is opened.
"""
try:
with self._db_connection() as conn:
cursor = conn.cursor()
# Recent advert paths (most-recent 50). The idx_observed_paths_advert_pk_seen covering
# index serves WHERE public_key=? AND packet_type='advert' ORDER BY last_seen DESC directly.
all_paths: list[dict[str, Any]] = []
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='observed_paths'")
if cursor.fetchone():
cursor.execute("""
SELECT path_hex, path_length, bytes_per_hop, observation_count, last_seen
FROM observed_paths
WHERE packet_type = 'advert' AND public_key = ?
ORDER BY last_seen DESC
LIMIT 50
""", (public_key,))
for prow in cursor.fetchall():
if not prow['path_hex']:
continue
bph = None
if prow['bytes_per_hop'] is not None:
try:
bph = int(prow['bytes_per_hop'])
if bph not in (1, 2, 3):
bph = 1
except (TypeError, ValueError):
bph = 1
all_paths.append({
'path_hex': prow['path_hex'],
'path_length': int(prow['path_length']) if prow['path_length'] is not None else 0,
'bytes_per_hop': bph,
'observation_count': int(prow['observation_count']) if prow['observation_count'] is not None else 1,
'last_seen': prow['last_seen'] if prow['last_seen'] is not None else None,
})
# Raw advertisement data for the "Advertisement Data" modal.
raw_advert_data = None
raw_advert_data_parsed = None
cursor.execute("""
SELECT raw_advert_data FROM complete_contact_tracking
WHERE public_key = ? ORDER BY last_heard DESC LIMIT 1
""", (public_key,))
rad_row = cursor.fetchone()
if rad_row and rad_row['raw_advert_data']:
raw_advert_data = rad_row['raw_advert_data']
try:
import json
raw_advert_data_parsed = json.loads(raw_advert_data)
except Exception:
raw_advert_data_parsed = None
return {
'all_paths': all_paths,
'raw_advert_data': raw_advert_data,
'raw_advert_data_parsed': raw_advert_data_parsed,
}
except Exception as e:
self.logger.error(f"Error getting contact detail: {e}")
return {'error': str(e)}
def _calculate_distance(self, lat1, lon1, lat2, lon2):
"""Calculate distance between two points using Haversine formula"""
import math
# Convert latitude and longitude from degrees to radians
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
# Haversine formula
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
c = 2 * math.asin(math.sqrt(a))
# Radius of earth in kilometers
r = 6371
return c * r