Jun 24 - Implement devices tracker - Update
This commit is contained in:
+28
-21
@@ -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
@@ -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
@@ -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).',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user