Jun 24 - Implement devices tracker

This commit is contained in:
Nguyen Ngo
2026-06-24 17:00:00 -04:00
parent a40eaa9106
commit 6d249d88f1
8 changed files with 506 additions and 0 deletions
+2
View File
@@ -165,6 +165,7 @@ def create_app(config_name='default'):
from app.routes import scheduled_reports # Phase 6 — Scheduled reports
from app.routes import support # Support chat + admin tickets
from app.routes import broadcast # Admin broadcast notifications
from app.routes import devices # Admin device registry
app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.bp)
@@ -180,6 +181,7 @@ def create_app(config_name='default'):
app.register_blueprint(scheduled_reports.bp)
app.register_blueprint(support.bp)
app.register_blueprint(broadcast.bp)
app.register_blueprint(devices.bp)
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
+3
View File
@@ -50,4 +50,7 @@ def register_api(app):
from app.api.comments import bp as comments_bp
api_bp.register_blueprint(comments_bp)
from app.api.devices import bp as devices_bp
api_bp.register_blueprint(devices_bp)
app.register_blueprint(api_bp)
+95
View File
@@ -0,0 +1,95 @@
"""
app/api/devices.py
------------------
Mobile API endpoint for device registration.
POST /api/v1/devices/register
Upserts a device record for the authenticated user.
Called on every app foreground (active scenePhase) so last_seen_at
stays current and the admin can identify stale / outdated installs.
Request JSON
------------
{
"device_id": "stable-uuid-from-keychain", // required
"device_name": "Nguyen's iPad", // UIDevice.current.name
"app_version": "1.2.0", // CFBundleShortVersionString
"ios_version": "18.3.1" // UIDevice.current.systemVersion
}
Response 200
------------
{ "ok": true, "data": { "registered": true } }
"""
import logging
from flask import Blueprint, request, g
from app import db
from app.models.device_registration import DeviceRegistration
from app.api.errors import api_ok, api_error
from app.api.decorators import jwt_required
from app.utils.audit import log_action, ACTION_UPDATE, ACTION_CREATE
from app.utils.time_utils import now_eastern
logger = logging.getLogger(__name__)
bp = Blueprint('api_devices', __name__)
_ALLOWED_ROLES = {'admin', 'director', 'inspector', 'project_manager'}
@bp.route('/devices/register', methods=['POST'])
@jwt_required
def register_device():
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
device_id = (data.get('device_id') or '').strip()
device_name = (data.get('device_name') or '').strip()[:255]
app_version = (data.get('app_version') or '').strip()[:32]
ios_version = (data.get('ios_version') or '').strip()[:32]
if not device_id:
return api_error('device_id is required', 400)
if len(device_id) > 64:
return api_error('device_id too long', 400)
now = now_eastern()
existing = DeviceRegistration.query.filter_by(device_id=device_id).first()
if existing:
# Update — always refresh last_seen_at and app/ios version
existing.user_id = user.id # re-bind if different user logs in same device
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
db.session.commit()
log_action(ACTION_UPDATE, 'DeviceRegistration', existing.id,
f'{device_name} v{app_version}',
f'user={user.username}; ios={ios_version}')
logger.info('API DEVICES | updated | device_id=%s | user=%s | app=%s',
device_id[:8], user.username, app_version)
else:
reg = DeviceRegistration(
device_id = device_id,
user_id = user.id,
device_name = device_name,
app_version = app_version,
ios_version = ios_version,
registered_at = now,
last_seen_at = now,
)
db.session.add(reg)
db.session.commit()
log_action(ACTION_CREATE, 'DeviceRegistration', reg.id,
f'{device_name} v{app_version}',
f'user={user.username}; ios={ios_version}')
logger.info('API DEVICES | registered | device_id=%s | user=%s | app=%s',
device_id[:8], user.username, app_version)
return api_ok({'registered': True})
+24
View File
@@ -0,0 +1,24 @@
# app/models/device_registration.py
# -----------------------------------
# Tracks iOS devices that have registered with the server.
# One row per physical device — upserted on every app foreground.
from app import db
from app.utils.time_utils import now_eastern
class DeviceRegistration(db.Model):
__tablename__ = 'device_registrations'
id = db.Column(db.Integer, primary_key=True)
# Stable UUID generated on first launch and stored in iOS Keychain.
# Unique across all devices; survives app restarts but not device wipes.
device_id = db.Column(db.String(64), nullable=False, unique=True, index=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
device_name = db.Column(db.String(255), nullable=False, default='')
app_version = db.Column(db.String(32), nullable=False, default='')
ios_version = db.Column(db.String(32), nullable=False, default='')
registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
last_seen_at = db.Column(db.DateTime, nullable=False, default=now_eastern)
user = db.relationship('User', backref=db.backref('devices', lazy='dynamic'))
+108
View File
@@ -0,0 +1,108 @@
# app/routes/devices.py
# ----------------------
# Admin-only page for viewing registered iOS devices and pushing
# "please update" notifications to users on outdated app versions.
import logging
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.notification import Notification
from app.utils.decorators import admin_required
from app.utils.audit import log_action, ACTION_CREATE
logger = logging.getLogger(__name__)
bp = Blueprint('devices', __name__, url_prefix='/admin/devices')
@bp.route('/', methods=['GET'])
@login_required
@admin_required
def index():
"""Show all registered devices, most-recently-seen first."""
devices = (
DeviceRegistration.query
.order_by(DeviceRegistration.last_seen_at.desc())
.all()
)
return render_template('admin/devices.html', devices=devices)
@bp.route('/notify', methods=['POST'])
@login_required
@admin_required
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()
if not current_version:
flash('Current version is required.', 'danger')
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:
return (0,)
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]
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:
if device.user_id in seen_users:
continue
seen_users.add(device.user_id)
body = custom_message or (
f'A new version of JanitorialQC ({current_version}) is available. '
f'Please update from the App Store to get the latest features and fixes.'
)
notif = Notification(
user_id = device.user_id,
title = f'App Update Available — v{current_version}',
body = body,
link = None,
event_type = 'admin_broadcast',
is_read = False,
)
db.session.add(notif)
notified += 1
db.session.commit()
log_action(ACTION_CREATE, 'DeviceUpdateNotice', 0,
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)
flash(
f'Update notice sent to {notified} user(s) on {len(outdated)} outdated device(s).',
'success'
)
return redirect(url_for('devices.index'))
+227
View File
@@ -0,0 +1,227 @@
{% extends "base.html" %}
{% block title %}Device Registry{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<h2><i class="bi bi-tablet me-2"></i>Device Registry</h2>
<p class="text-muted mb-0">
iOS devices that have opened the JanitorialQC app.
Records update on every app launch — <strong>Last Seen</strong> reflects the most recent foreground.
</p>
</div>
<div class="col-auto">
<span class="badge bg-secondary fs-6">{{ devices|length }} device{{ 's' if devices|length != 1 }}</span>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<div class="row g-4">
{# ── Device Table ──────────────────────────────────────────────────── #}
<div class="col-xl-8">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center gap-2">
<i class="bi bi-list-ul"></i>
<strong>Registered Devices</strong>
<span class="text-muted fw-normal ms-1 small">sorted by most recently active</span>
<div class="ms-auto">
<input type="text" id="deviceSearch" class="form-control form-control-sm"
placeholder="Filter…" style="width:180px;">
</div>
</div>
<div class="card-body p-0">
{% if devices %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0" id="deviceTable">
<thead class="table-light">
<tr>
<th>Device</th>
<th>User</th>
<th class="text-center">App Ver.</th>
<th class="text-center">iOS Ver.</th>
<th>Last Seen</th>
<th>Registered</th>
</tr>
</thead>
<tbody>
{% for d in devices %}
{% set app_ver = d.app_version or '—' %}
<tr>
<td>
<i class="bi bi-tablet me-1 text-muted"></i>
{{ d.device_name or '—' }}
</td>
<td>
<span class="fw-semibold">{{ d.user.display_name if d.user else '—' }}</span>
{% if d.user %}
<br><span class="text-muted small">{{ d.user.username }}</span>
{% endif %}
</td>
<td class="text-center">
<span class="badge version-badge" data-version="{{ d.app_version }}">
{{ app_ver }}
</span>
</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>
<span class="text-muted" style="font-size:.75rem;">
{{ d.last_seen_at.strftime('%I:%M %p') }}
</span>
</td>
<td class="text-nowrap small text-muted">
{{ d.registered_at.strftime('%b %-d, %Y') }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-tablet fs-1 d-block mb-2 opacity-25"></i>
No devices registered yet. Devices appear here once the iOS app opens while online.
</div>
{% endif %}
</div>
</div>
</div>
{# ── Send Update Notice ────────────────────────────────────────────── #}
<div class="col-xl-4">
<div class="card shadow-sm">
<div class="card-header bg-warning text-dark">
<i class="bi bi-bell-fill me-2"></i><strong>Send Update Notice</strong>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
Sends an in-app notification to every user whose device is running
an app version <em>older</em> than the version you enter.
Delivered within 60 seconds via the app's background poll.
One notification per user (even if they have multiple devices).
</p>
<form method="POST" action="{{ url_for('devices.notify_update') }}"
onsubmit="return confirm('Send update notice to all users on older versions?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="current_version" class="form-label fw-semibold">
Current / Target Version <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="current_version"
name="current_version" placeholder="e.g. 1.3.0"
pattern="^\d+\.\d+(\.\d+)?$"
title="Format: 1.0 or 1.3.0"
required>
<div class="form-text">
Devices running a version below this will receive the notice.
</div>
</div>
<div class="mb-4">
<label for="message" class="form-label fw-semibold">
Custom Message <span class="text-muted fw-normal">(optional)</span>
</label>
<textarea class="form-control" id="message" name="message"
rows="3" maxlength="500"
placeholder="Leave blank to use the default message."></textarea>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-warning">
<i class="bi bi-send-fill me-2"></i>Send Update Notice
</button>
</div>
</form>
{% if devices %}
<hr class="mt-4">
<p class="text-muted small mb-2 fw-semibold">Version breakdown</p>
<div id="versionBreakdown"></div>
{% endif %}
</div>
</div>
</div>
</div><!-- /row -->
<script>
// ── Inline filter ──────────────────────────────────────────────────────────
(function () {
var input = document.getElementById('deviceSearch');
if (!input) return;
input.addEventListener('input', function () {
var q = this.value.toLowerCase();
document.querySelectorAll('#deviceTable tbody tr').forEach(function (row) {
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
});
});
})();
// ── Version badges + breakdown ────────────────────────────────────────────
(function () {
var badges = document.querySelectorAll('.version-badge[data-version]');
if (!badges.length) return;
// Collect all version strings from the table
var versions = Array.from(badges).map(function (b) { return b.dataset.version || ''; });
// Find the most-common latest version to use as the "current" baseline
var freq = {};
versions.forEach(function (v) { if (v) freq[v] = (freq[v] || 0) + 1; });
var sorted = Object.keys(freq).sort(function (a, b) {
return compareSemver(b, a); // desc — highest first
});
var latest = sorted[0] || '';
// Colour each badge relative to the latest version seen in the table
badges.forEach(function (badge) {
var v = badge.dataset.version || '';
if (!v || v === '—') {
badge.classList.add('bg-secondary');
} else if (v === latest) {
badge.classList.add('bg-success');
} else if (compareSemver(v, latest) < 0) {
badge.classList.add('bg-danger');
} else {
badge.classList.add('bg-secondary');
}
});
// Version breakdown summary
var breakdown = document.getElementById('versionBreakdown');
if (breakdown && sorted.length) {
var html = '<ul class="list-unstyled mb-0">';
sorted.forEach(function (v) {
var cls = (v === latest) ? 'text-success fw-semibold' : 'text-danger';
html += '<li class="d-flex justify-content-between small mb-1">'
+ '<span class="' + cls + '">' + v + '</span>'
+ '<span class="badge bg-secondary rounded-pill">' + freq[v] + ' device' + (freq[v] === 1 ? '' : 's') + '</span>'
+ '</li>';
});
html += '</ul>';
breakdown.innerHTML = html;
}
function compareSemver(a, b) {
var pa = a.split('.').map(Number);
var pb = b.split('.').map(Number);
for (var i = 0; i < Math.max(pa.length, pb.length); i++) {
var diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
})();
</script>
{% endblock %}
+6
View File
@@ -208,6 +208,12 @@
<i class="bi bi-megaphone-fill"></i> Broadcast
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('devices.') }}"
href="{{ url_for('devices.index') }}">
<i class="bi bi-tablet"></i> Devices
</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
@@ -0,0 +1,41 @@
"""phase30 — device_registrations table for iOS device tracking
Revision ID: phase30_device_registry
Down revision: phase29_broadcasts
"""
revision = 'phase30_device_registry'
down_revision = 'phase29_broadcasts'
from alembic import op
import sqlalchemy as sa
def upgrade():
# Guard: skip if table already exists (safe to re-run)
conn = op.get_bind()
exists = conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'device_registrations'"
)).scalar()
if exists:
return
op.create_table(
'device_registrations',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('device_id', sa.String(64), nullable=False),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('device_name', sa.String(255), nullable=False, server_default=''),
sa.Column('app_version', sa.String(32), nullable=False, server_default=''),
sa.Column('ios_version', sa.String(32), nullable=False, server_default=''),
sa.Column('registered_at', sa.DateTime(), nullable=False),
sa.Column('last_seen_at', sa.DateTime(), nullable=False),
)
op.create_index('uq_device_id', 'device_registrations', ['device_id'], unique=True)
op.create_index('ix_device_registrations_user_id', 'device_registrations', ['user_id'])
def downgrade():
op.drop_table('device_registrations')