diff --git a/app/api/auth.py b/app/api/auth.py index 3c01af4..67254ed 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -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": "", - "apns_token": "", - "device_name": "John's iPhone", // optional - "app_version": "1.0.3" // optional + "device_id": "", + "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}) \ No newline at end of file + return api_ok({'registered': True}) diff --git a/app/models/api_token.py b/app/models/api_token.py index 9dff3e9..168b4a9 100644 --- a/app/models/api_token.py +++ b/app/models/api_token.py @@ -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'), diff --git a/app/routes/devices.py b/app/routes/devices.py index 6ad7a59..92267f5 100644 --- a/app/routes/devices.py +++ b/app/routes/devices.py @@ -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).', diff --git a/app/templates/admin/devices.html b/app/templates/admin/devices.html index 2538e54..b7c5625 100644 --- a/app/templates/admin/devices.html +++ b/app/templates/admin/devices.html @@ -74,9 +74,9 @@ {{ d.ios_version or '—' }} - {{ d.last_seen_at.strftime('%b %-d, %Y') }}
+ {{ (d.last_seen_at or d.registered_at).strftime('%b %-d, %Y') }}
- {{ d.last_seen_at.strftime('%I:%M %p') }} + {{ (d.last_seen_at or d.registered_at).strftime('%I:%M %p') }} diff --git a/migrations/versions/phase31_device_registry.py b/migrations/versions/phase31_device_registry.py index 26bc441..e02b160 100644 --- a/migrations/versions/phase31_device_registry.py +++ b/migrations/versions/phase31_device_registry.py @@ -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") \ No newline at end of file + 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")