Jun 24 - Implement devices tracker - Update
This commit is contained in:
+28
-21
@@ -279,18 +279,20 @@ def me():
|
||||
@jwt_required
|
||||
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
|
||||
has the current token (APNs rotates tokens periodically).
|
||||
Called on every app launch so the server always has the current
|
||||
app version and iOS version for the admin Devices page.
|
||||
apns_token is optional (empty string when APNs push is not configured).
|
||||
|
||||
Request JSON
|
||||
------------
|
||||
{
|
||||
"device_id": "<UIDevice.identifierForVendor>",
|
||||
"apns_token": "<hex_string_from_didRegisterForRemoteNotifications>",
|
||||
"device_name": "John's iPhone", // optional
|
||||
"app_version": "1.0.3" // optional
|
||||
"device_id": "<stable UUID from Keychain>",
|
||||
"device_name": "Nguyen\'s iPad",
|
||||
"app_version": "1.0.3",
|
||||
"ios_version": "18.3.1",
|
||||
"apns_token": ""
|
||||
}
|
||||
|
||||
Response 200
|
||||
@@ -302,32 +304,37 @@ def register_device():
|
||||
apns_token = (data.get('apns_token') or '').strip()[:200]
|
||||
device_name = (data.get('device_name') or '').strip()[:100] 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:
|
||||
return api_error('device_id and apns_token are required', 400)
|
||||
if not device_id:
|
||||
return api_error('device_id is required', 400)
|
||||
|
||||
now = now_eastern()
|
||||
|
||||
# Upsert: update existing row or insert new one
|
||||
existing = DeviceToken.query.filter_by(
|
||||
user_id=g.api_user.id,
|
||||
device_id=device_id,
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.apns_token = apns_token
|
||||
existing.device_name = device_name
|
||||
existing.app_version = app_version
|
||||
existing.registered_at = now_eastern()
|
||||
existing.apns_token = apns_token or existing.apns_token
|
||||
existing.device_name = device_name or existing.device_name
|
||||
existing.app_version = app_version or existing.app_version
|
||||
existing.ios_version = ios_version or existing.ios_version
|
||||
existing.last_seen_at = now
|
||||
else:
|
||||
db.session.add(DeviceToken(
|
||||
user_id = g.api_user.id,
|
||||
device_id = device_id,
|
||||
apns_token = apns_token,
|
||||
device_name = device_name,
|
||||
app_version = app_version,
|
||||
user_id = g.api_user.id,
|
||||
device_id = device_id,
|
||||
apns_token = apns_token,
|
||||
device_name = device_name,
|
||||
app_version = app_version,
|
||||
ios_version = ios_version,
|
||||
last_seen_at = now,
|
||||
))
|
||||
|
||||
db.session.commit()
|
||||
logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | apns_token=...%s',
|
||||
g.api_user.username, device_id, apns_token[-6:])
|
||||
logger.info('API DEVICE REGISTERED | user=%s | device_id=%s | app=%s | ios=%s',
|
||||
g.api_user.username, device_id[:8], app_version, ios_version)
|
||||
|
||||
return api_ok({'registered': True})
|
||||
+15
-12
@@ -10,9 +10,9 @@ RefreshToken
|
||||
Revocation is instant: delete the row.
|
||||
|
||||
DeviceToken
|
||||
One row per (user, device) pair. Stores the APNs token so the server
|
||||
can push notifications to the device. Updated on every app launch
|
||||
because APNs tokens can rotate.
|
||||
One row per (user, device) pair. Stores device info so the admin can
|
||||
see all installed devices and their versions. Updated on every app
|
||||
launch because APNs tokens can rotate.
|
||||
"""
|
||||
|
||||
import secrets
|
||||
@@ -100,25 +100,28 @@ class RefreshToken(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.
|
||||
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'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('users.id', ondelete='CASCADE'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
device_id = db.Column(db.String(64), nullable=False) # UIDevice.identifierForVendor
|
||||
apns_token = db.Column(db.String(200), nullable=False)
|
||||
device_name = db.Column(db.String(100), nullable=True)
|
||||
app_version = db.Column(db.String(20), nullable=True)
|
||||
registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
|
||||
device_id = db.Column(db.String(64), nullable=False)
|
||||
apns_token = db.Column(db.String(200), nullable=False, default='')
|
||||
device_name = db.Column(db.String(100), nullable=True)
|
||||
app_version = db.Column(db.String(20), nullable=True)
|
||||
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__ = (
|
||||
db.UniqueConstraint('user_id', 'device_id', name='uq_device_token_user_device'),
|
||||
|
||||
+7
-16
@@ -8,7 +8,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
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.utils.decorators import admin_required
|
||||
from app.utils.audit import log_action, ACTION_CREATE
|
||||
@@ -24,8 +24,8 @@ bp = Blueprint('devices', __name__, url_prefix='/admin/devices')
|
||||
def index():
|
||||
"""Show all registered devices, most-recently-seen first."""
|
||||
devices = (
|
||||
DeviceRegistration.query
|
||||
.order_by(DeviceRegistration.last_seen_at.desc())
|
||||
DeviceToken.query
|
||||
.order_by(DeviceToken.last_seen_at.desc().nullslast())
|
||||
.all()
|
||||
)
|
||||
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
|
||||
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()
|
||||
custom_message = (request.form.get('message') or '').strip()
|
||||
@@ -52,7 +47,6 @@ def notify_update():
|
||||
return redirect(url_for('devices.index'))
|
||||
|
||||
def version_tuple(v: str):
|
||||
"""Convert "1.3.0" → (1, 3, 0) for comparison. Non-numeric parts → 0."""
|
||||
try:
|
||||
return tuple(int(x) for x in v.strip().split('.'))
|
||||
except ValueError:
|
||||
@@ -60,16 +54,13 @@ def notify_update():
|
||||
|
||||
target_v = version_tuple(current_version)
|
||||
|
||||
# Find all devices running an older version
|
||||
all_devices = DeviceRegistration.query.all()
|
||||
outdated = [d for d in all_devices if version_tuple(d.app_version) < target_v]
|
||||
all_devices = DeviceToken.query.all()
|
||||
outdated = [d for d in all_devices if version_tuple(d.app_version or '0') < target_v]
|
||||
|
||||
if not outdated:
|
||||
flash(f'No devices found running a version older than {current_version}.', 'info')
|
||||
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()
|
||||
notified = 0
|
||||
for device in outdated:
|
||||
@@ -98,8 +89,8 @@ def notify_update():
|
||||
f'v{current_version} notice → {notified} user(s)',
|
||||
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',
|
||||
current_version, notified, len(outdated), current_user.username)
|
||||
logger.info('DEVICES | update_notice | version=%s | users_notified=%d | by=%s',
|
||||
current_version, notified, current_user.username)
|
||||
|
||||
flash(
|
||||
f'Update notice sent to {notified} user(s) on {len(outdated)} outdated device(s).',
|
||||
|
||||
@@ -74,9 +74,9 @@
|
||||
</td>
|
||||
<td class="text-center text-muted small">{{ d.ios_version or '—' }}</td>
|
||||
<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;">
|
||||
{{ d.last_seen_at.strftime('%I:%M %p') }}
|
||||
{{ (d.last_seen_at or d.registered_at).strftime('%I:%M %p') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-nowrap small text-muted">
|
||||
|
||||
@@ -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
|
||||
Flask-Migrate and left the table uncreated even though alembic_version
|
||||
recorded phase30 as applied. This migration re-creates the table using
|
||||
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.
|
||||
Extends the existing api_device_tokens table (phase7) so the admin
|
||||
Devices page can show iOS version and time of last app launch.
|
||||
Drops the unused device_registrations table created by phase30 if it exists.
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
@@ -17,23 +14,29 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# Add ios_version column if it doesn't exist
|
||||
op.execute("""
|
||||
CREATE TABLE IF NOT EXISTS device_registrations (
|
||||
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
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
|
||||
ALTER TABLE api_device_tokens
|
||||
ADD COLUMN IF NOT EXISTS ios_version VARCHAR(20) NULL
|
||||
""")
|
||||
|
||||
# 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():
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user