Jun 24 - Implement devices tracker - Update

This commit is contained in:
Nguyen Ngo
2026-06-24 17:44:38 -04:00
parent 464dc86b0d
commit 2293f88f42
5 changed files with 78 additions and 74 deletions
+29 -22
View File
@@ -279,18 +279,20 @@ def me():
@jwt_required @jwt_required
def register_device(): def register_device():
""" """
Register or update the APNs device token for the authenticated user. Register or update device info for the authenticated user.
Called on every app launch after authentication so the server always Called on every app launch so the server always has the current
has the current token (APNs rotates tokens periodically). app version and iOS version for the admin Devices page.
apns_token is optional (empty string when APNs push is not configured).
Request JSON Request JSON
------------ ------------
{ {
"device_id": "<UIDevice.identifierForVendor>", "device_id": "<stable UUID from Keychain>",
"apns_token": "<hex_string_from_didRegisterForRemoteNotifications>", "device_name": "Nguyen\'s iPad",
"device_name": "John's iPhone", // optional "app_version": "1.0.3",
"app_version": "1.0.3" // optional "ios_version": "18.3.1",
"apns_token": ""
} }
Response 200 Response 200
@@ -302,32 +304,37 @@ def register_device():
apns_token = (data.get('apns_token') or '').strip()[:200] apns_token = (data.get('apns_token') or '').strip()[:200]
device_name = (data.get('device_name') or '').strip()[:100] or None device_name = (data.get('device_name') or '').strip()[:100] or None
app_version = (data.get('app_version') or '').strip()[:20] or None app_version = (data.get('app_version') or '').strip()[:20] or None
ios_version = (data.get('ios_version') or '').strip()[:20] or None
if not device_id or not apns_token: if not device_id:
return api_error('device_id and apns_token are required', 400) return api_error('device_id is required', 400)
now = now_eastern()
# Upsert: update existing row or insert new one
existing = DeviceToken.query.filter_by( existing = DeviceToken.query.filter_by(
user_id=g.api_user.id, user_id=g.api_user.id,
device_id=device_id, device_id=device_id,
).first() ).first()
if existing: if existing:
existing.apns_token = apns_token existing.apns_token = apns_token or existing.apns_token
existing.device_name = device_name existing.device_name = device_name or existing.device_name
existing.app_version = app_version existing.app_version = app_version or existing.app_version
existing.registered_at = now_eastern() existing.ios_version = ios_version or existing.ios_version
existing.last_seen_at = now
else: else:
db.session.add(DeviceToken( db.session.add(DeviceToken(
user_id = g.api_user.id, user_id = g.api_user.id,
device_id = device_id, device_id = device_id,
apns_token = apns_token, apns_token = apns_token,
device_name = device_name, device_name = device_name,
app_version = app_version, app_version = app_version,
ios_version = ios_version,
last_seen_at = now,
)) ))
db.session.commit() db.session.commit()
logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | apns_token=...%s', logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | app=%s | ios=%s',
g.api_user.username, device_id, apns_token[-6:]) g.api_user.username, device_id[:8], app_version, ios_version)
return api_ok({'registered': True}) return api_ok({'registered': True})
+15 -12
View File
@@ -10,9 +10,9 @@ RefreshToken
Revocation is instant: delete the row. Revocation is instant: delete the row.
DeviceToken DeviceToken
One row per (user, device) pair. Stores the APNs token so the server One row per (user, device) pair. Stores device info so the admin can
can push notifications to the device. Updated on every app launch see all installed devices and their versions. Updated on every app
because APNs tokens can rotate. launch because APNs tokens can rotate.
""" """
import secrets import secrets
@@ -100,25 +100,28 @@ class RefreshToken(db.Model):
class DeviceToken(db.Model): class DeviceToken(db.Model):
""" """
APNs device token for push notification delivery. Device record for admin tracking and optional APNs push delivery.
One row per (user, device_id) pair — upserted on every app launch. One row per (user, device_id) pair — upserted on every app launch.
The apns_token is the hex string returned by the iOS SDK. apns_token is optional (empty string when APNs push is not configured).
ios_version and last_seen_at added in phase31 for the admin Devices page.
""" """
__tablename__ = 'api_device_tokens' __tablename__ = 'api_device_tokens'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column( user_id = db.Column(
db.Integer, db.Integer,
db.ForeignKey('users.id', ondelete='CASCADE'), db.ForeignKey('users.id', ondelete='CASCADE'),
nullable=False, nullable=False,
index=True, index=True,
) )
device_id = db.Column(db.String(64), nullable=False) # UIDevice.identifierForVendor device_id = db.Column(db.String(64), nullable=False)
apns_token = db.Column(db.String(200), nullable=False) apns_token = db.Column(db.String(200), nullable=False, default='')
device_name = db.Column(db.String(100), nullable=True) device_name = db.Column(db.String(100), nullable=True)
app_version = db.Column(db.String(20), nullable=True) app_version = db.Column(db.String(20), nullable=True)
registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern) ios_version = db.Column(db.String(20), nullable=True)
registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
last_seen_at = db.Column(db.DateTime, nullable=True)
__table_args__ = ( __table_args__ = (
db.UniqueConstraint('user_id', 'device_id', name='uq_device_token_user_device'), db.UniqueConstraint('user_id', 'device_id', name='uq_device_token_user_device'),
+7 -16
View File
@@ -8,7 +8,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import db from app import db
from app.models.device_registration import DeviceRegistration from app.models.api_token import DeviceToken
from app.models.notification import Notification from app.models.notification import Notification
from app.utils.decorators import admin_required from app.utils.decorators import admin_required
from app.utils.audit import log_action, ACTION_CREATE from app.utils.audit import log_action, ACTION_CREATE
@@ -24,8 +24,8 @@ bp = Blueprint('devices', __name__, url_prefix='/admin/devices')
def index(): def index():
"""Show all registered devices, most-recently-seen first.""" """Show all registered devices, most-recently-seen first."""
devices = ( devices = (
DeviceRegistration.query DeviceToken.query
.order_by(DeviceRegistration.last_seen_at.desc()) .order_by(DeviceToken.last_seen_at.desc().nullslast())
.all() .all()
) )
return render_template('admin/devices.html', devices=devices) return render_template('admin/devices.html', devices=devices)
@@ -38,11 +38,6 @@ def notify_update():
""" """
Send an in-app update notice to all users whose app_version is below Send an in-app update notice to all users whose app_version is below
the version string entered by the admin. the version string entered by the admin.
Form fields
-----------
current_version str The version string to treat as current (e.g. "1.3.0")
message str Optional custom message body (default provided)
""" """
current_version = (request.form.get('current_version') or '').strip() current_version = (request.form.get('current_version') or '').strip()
custom_message = (request.form.get('message') or '').strip() custom_message = (request.form.get('message') or '').strip()
@@ -52,7 +47,6 @@ def notify_update():
return redirect(url_for('devices.index')) return redirect(url_for('devices.index'))
def version_tuple(v: str): def version_tuple(v: str):
"""Convert "1.3.0" → (1, 3, 0) for comparison. Non-numeric parts → 0."""
try: try:
return tuple(int(x) for x in v.strip().split('.')) return tuple(int(x) for x in v.strip().split('.'))
except ValueError: except ValueError:
@@ -60,16 +54,13 @@ def notify_update():
target_v = version_tuple(current_version) target_v = version_tuple(current_version)
# Find all devices running an older version all_devices = DeviceToken.query.all()
all_devices = DeviceRegistration.query.all() outdated = [d for d in all_devices if version_tuple(d.app_version or '0') < target_v]
outdated = [d for d in all_devices if version_tuple(d.app_version) < target_v]
if not outdated: if not outdated:
flash(f'No devices found running a version older than {current_version}.', 'info') flash(f'No devices found running a version older than {current_version}.', 'info')
return redirect(url_for('devices.index')) return redirect(url_for('devices.index'))
# Deduplicate by user_id — one notification per user even if they have
# multiple devices registered (e.g. primary + secondary server devices).
seen_users = set() seen_users = set()
notified = 0 notified = 0
for device in outdated: for device in outdated:
@@ -98,8 +89,8 @@ def notify_update():
f'v{current_version} notice → {notified} user(s)', f'v{current_version} notice → {notified} user(s)',
f'outdated_devices={len(outdated)}; sent_by={current_user.username}') f'outdated_devices={len(outdated)}; sent_by={current_user.username}')
logger.info('DEVICES | update_notice | version=%s | users_notified=%d | devices_outdated=%d | by=%s', logger.info('DEVICES | update_notice | version=%s | users_notified=%d | by=%s',
current_version, notified, len(outdated), current_user.username) current_version, notified, current_user.username)
flash( flash(
f'Update notice sent to {notified} user(s) on {len(outdated)} outdated device(s).', f'Update notice sent to {notified} user(s) on {len(outdated)} outdated device(s).',
+2 -2
View File
@@ -74,9 +74,9 @@
</td> </td>
<td class="text-center text-muted small">{{ d.ios_version or '—' }}</td> <td class="text-center text-muted small">{{ d.ios_version or '—' }}</td>
<td class="text-nowrap small"> <td class="text-nowrap small">
{{ d.last_seen_at.strftime('%b %-d, %Y') }}<br> {{ (d.last_seen_at or d.registered_at).strftime('%b %-d, %Y') }}<br>
<span class="text-muted" style="font-size:.75rem;"> <span class="text-muted" style="font-size:.75rem;">
{{ d.last_seen_at.strftime('%I:%M %p') }} {{ (d.last_seen_at or d.registered_at).strftime('%I:%M %p') }}
</span> </span>
</td> </td>
<td class="text-nowrap small text-muted"> <td class="text-nowrap small text-muted">
+25 -22
View File
@@ -1,11 +1,8 @@
"""phase31 — device_registrations table (re-issue of phase30) """phase31 — add ios_version and last_seen_at to api_device_tokens
phase30 used op.get_bind() + op.create_table() which is unreliable with Extends the existing api_device_tokens table (phase7) so the admin
Flask-Migrate and left the table uncreated even though alembic_version Devices page can show iOS version and time of last app launch.
recorded phase30 as applied. This migration re-creates the table using Drops the unused device_registrations table created by phase30 if it exists.
the same raw-SQL pattern used by every other migration in this project.
CREATE TABLE IF NOT EXISTS is idempotent — safe whether or not the table
was partially created by the broken phase30.
""" """
from alembic import op from alembic import op
@@ -17,23 +14,29 @@ depends_on = None
def upgrade(): def upgrade():
# Add ios_version column if it doesn't exist
op.execute(""" op.execute("""
CREATE TABLE IF NOT EXISTS device_registrations ( ALTER TABLE api_device_tokens
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, ADD COLUMN IF NOT EXISTS ios_version VARCHAR(20) NULL
device_id VARCHAR(64) NOT NULL,
user_id INT NOT NULL,
device_name VARCHAR(255) NOT NULL DEFAULT '',
app_version VARCHAR(32) NOT NULL DEFAULT '',
ios_version VARCHAR(32) NOT NULL DEFAULT '',
registered_at DATETIME NOT NULL,
last_seen_at DATETIME NOT NULL,
CONSTRAINT uq_device_id UNIQUE (device_id),
CONSTRAINT fk_device_reg_user
FOREIGN KEY (user_id) REFERENCES users(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""") """)
# Add last_seen_at column if it doesn't exist
op.execute("""
ALTER TABLE api_device_tokens
ADD COLUMN IF NOT EXISTS last_seen_at DATETIME NULL
""")
# Backfill last_seen_at from registered_at for existing rows
op.execute("""
UPDATE api_device_tokens
SET last_seen_at = registered_at
WHERE last_seen_at IS NULL
""")
# Drop the incorrectly created device_registrations table from phase30 if present
op.execute("DROP TABLE IF EXISTS device_registrations")
def downgrade(): def downgrade():
op.execute("DROP TABLE IF EXISTS device_registrations") op.execute("ALTER TABLE api_device_tokens DROP COLUMN IF EXISTS ios_version")
op.execute("ALTER TABLE api_device_tokens DROP COLUMN IF EXISTS last_seen_at")