Jun 26 update to the latest codes

This commit is contained in:
2026-06-26 12:09:49 -04:00
parent 77678ed724
commit 5d95cdbbfc
15 changed files with 711 additions and 36 deletions
+4
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 scheduled_reports # Phase 6 — Scheduled reports
from app.routes import support # Support chat + admin tickets from app.routes import support # Support chat + admin tickets
from app.routes import broadcast # Admin broadcast notifications from app.routes import broadcast # Admin broadcast notifications
from app.routes import devices # Admin device registry
app.register_blueprint(auth.bp) app.register_blueprint(auth.bp)
app.register_blueprint(dashboard.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(scheduled_reports.bp)
app.register_blueprint(support.bp) app.register_blueprint(support.bp)
app.register_blueprint(broadcast.bp) app.register_blueprint(broadcast.bp)
app.register_blueprint(devices.bp)
# ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ─────────────────── # ── Mobile API (Phase 7 / Phase A / Phase B / Phase C) ───────────────────
# The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed. # The /api/v1 blueprint group uses JWT Bearer tokens — no CSRF cookies needed.
@@ -197,6 +199,7 @@ def create_app(config_name='default'):
from app.api.notifications import bp as _api_notifications_bp from app.api.notifications import bp as _api_notifications_bp
from app.api.stats import bp as _api_stats_bp from app.api.stats import bp as _api_stats_bp
from app.api.comments import bp as _api_comments_bp from app.api.comments import bp as _api_comments_bp
from app.api.devices import bp as _api_devices_bp
csrf.exempt(_api_auth_bp) csrf.exempt(_api_auth_bp)
csrf.exempt(_api_facilities_bp) csrf.exempt(_api_facilities_bp)
csrf.exempt(_api_templates_bp) csrf.exempt(_api_templates_bp)
@@ -206,6 +209,7 @@ def create_app(config_name='default'):
csrf.exempt(_api_notifications_bp) csrf.exempt(_api_notifications_bp)
csrf.exempt(_api_stats_bp) csrf.exempt(_api_stats_bp)
csrf.exempt(_api_comments_bp) csrf.exempt(_api_comments_bp)
csrf.exempt(_api_devices_bp)
register_api(app) register_api(app)
# ── Security response headers ───────────────────────────────────────── # ── Security response headers ─────────────────────────────────────────
+3
View File
@@ -50,4 +50,7 @@ def register_api(app):
from app.api.comments import bp as comments_bp from app.api.comments import bp as comments_bp
api_bp.register_blueprint(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) app.register_blueprint(api_bp)
+23 -16
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,21 +304,24 @@ 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,
@@ -324,10 +329,12 @@ def register_device():
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})
+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})
+61
View File
@@ -433,3 +433,64 @@ def update_issue_photos(issue_id):
issue.id, len(new_photos), user.username) issue.id, len(new_photos), user.username)
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)}) return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
# ── Attach Resolution Photos (mobile) ─────────────────────────────────────────
@bp.route('/issues/<int:issue_id>/result_photos', methods=['PATCH'])
@jwt_required
def update_issue_result_photos(issue_id):
"""
Attach resolution photos to an issue from the mobile app.
Called when an inspector marks an issue resolved and uploads photos
showing the fix. Stored in Issue.result_photos so they appear under
"Resolution Details" on the web — identical to photos uploaded via the
web update form.
Request JSON
------------
{ "result_photos": ["uploads/issue_result_photos/a.jpg", ...] }
Response 200
------------
{ "ok": true, "data": { "issue_id": 99, "result_photos_count": 2 } }
"""
user = g.api_user
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
issue = db.session.get(Issue, issue_id)
if issue is None:
return api_error('Issue not found', 404)
if user.role == 'inspector':
fids = get_inspector_scope(user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
return api_error('Access denied', 403)
data = request.get_json(silent=True) or {}
raw = data.get('result_photos')
if not isinstance(raw, list):
return api_error('result_photos must be a list of path strings', 400)
new_photos = [p for p in raw if isinstance(p, str) and p.strip()]
if not new_photos:
return api_error('result_photos must contain at least one valid path', 400)
# Merge idempotently with any existing result_photos
existing = issue.result_photos or []
merged = existing + [p for p in new_photos if p not in existing]
issue.result_photos = merged
db.session.commit()
log_action(ACTION_UPDATE, 'Issue', issue.id,
f'result_photos updated (+{len(new_photos)} photos)',
f'source=mobile; updated_by={user.username}')
logger.info('API ISSUES | result_photos_updated | issue_id=%d | added=%d | user=%s',
issue.id, len(new_photos), user.username)
return api_ok({'issue_id': issue.id, 'result_photos_count': len(merged)})
+3 -1
View File
@@ -46,7 +46,7 @@ def upload_photo():
Multipart form fields Multipart form fields
--------------------- ---------------------
file — binary image data (jpg / png / gif) file — binary image data (jpg / png / gif)
entity_type — "inspection" | "issue" (controls subfolder) entity_type — "inspection" | "issue" | "issue_result" (controls subfolder)
Response 200 Response 200
------------ ------------
@@ -80,6 +80,8 @@ def upload_photo():
# Determine destination subfolder # Determine destination subfolder
if entity_type == 'issue': if entity_type == 'issue':
subfolder = 'issue_photos' subfolder = 'issue_photos'
elif entity_type == 'issue_result':
subfolder = 'issue_result_photos'
else: else:
subfolder = 'inspection_photos' subfolder = 'inspection_photos'
+1
View File
@@ -6,3 +6,4 @@ from app.models.issue import Issue
from app.models.project import Project, CustomerAssignment from app.models.project import Project, CustomerAssignment
from app.models.api_token import RefreshToken, DeviceToken from app.models.api_token import RefreshToken, DeviceToken
from app.models.notification_matrix import NotificationMatrix from app.models.notification_matrix import NotificationMatrix
from app.models.device_registration import DeviceRegistration
+10 -7
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,10 +100,11 @@ 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'
@@ -114,11 +115,13 @@ class DeviceToken(db.Model):
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)
ios_version = db.Column(db.String(20), nullable=True)
registered_at = db.Column(db.DateTime, nullable=False, default=now_eastern) 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'),
+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'))
+99
View File
@@ -0,0 +1,99 @@
# 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.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
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 = (
DeviceToken.query
.order_by(db.func.coalesce(DeviceToken.last_seen_at, DeviceToken.registered_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.
"""
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):
try:
return tuple(int(x) for x in v.strip().split('.'))
except ValueError:
return (0,)
target_v = version_tuple(current_version)
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'))
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 | by=%s',
current_version, notified, 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 or d.registered_at).strftime('%b %-d, %Y') }}<br>
<span class="text-muted" style="font-size:.75rem;">
{{ (d.last_seen_at or d.registered_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 <i class="bi bi-megaphone-fill"></i> Broadcast
</a> </a>
</li> </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 %} {% endif %}
</ul> </ul>
<ul class="navbar-nav align-items-center"> <ul class="navbar-nav align-items-center">
@@ -0,0 +1,35 @@
"""phase30 — device_registrations table for iOS device tracking
Each row records a device that has opened the JanitorialQC app.
Upserted on every app foreground so last_seen_at stays current.
"""
from alembic import op
revision = 'phase30_device_registry'
down_revision = 'phase29_broadcasts'
branch_labels = None
depends_on = None
def upgrade():
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
""")
def downgrade():
op.execute("DROP TABLE IF EXISTS device_registrations")
@@ -0,0 +1,42 @@
"""phase31 — add ios_version and last_seen_at to api_device_tokens
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
revision = 'phase31_device_registry'
down_revision = 'phase30_device_registry'
branch_labels = None
depends_on = None
def upgrade():
# Add ios_version column if it doesn't exist
op.execute("""
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("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")
@@ -0,0 +1,66 @@
"""phase32 — add ios_version and last_seen_at to api_device_tokens
phase31 recorded as applied but ALTER statements never executed.
Uses INFORMATION_SCHEMA column-existence checks safe on MySQL 5.7+.
Also drops the orphaned device_registrations table from phase30 if present.
"""
revision = 'phase32_device_token_columns'
down_revision = 'phase31_device_registry'
branch_labels = None
depends_on = None
from alembic import op
import sqlalchemy as sa
def _column_exists(conn, table, column):
result = conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
"AND TABLE_NAME = :t AND COLUMN_NAME = :c"
), {"t": table, "c": column})
return result.scalar() > 0
def _table_exists(conn, table):
result = conn.execute(sa.text(
"SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES "
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t"
), {"t": table})
return result.scalar() > 0
def upgrade():
bind = op.get_bind()
if not _column_exists(bind, 'api_device_tokens', 'ios_version'):
op.execute(sa.text(
"ALTER TABLE api_device_tokens ADD COLUMN ios_version VARCHAR(20) NULL"
))
if not _column_exists(bind, 'api_device_tokens', 'last_seen_at'):
op.execute(sa.text(
"ALTER TABLE api_device_tokens ADD COLUMN last_seen_at DATETIME NULL"
))
op.execute(sa.text(
"UPDATE api_device_tokens SET last_seen_at = registered_at WHERE last_seen_at IS NULL"
))
if _table_exists(bind, 'device_registrations'):
op.execute(sa.text("DROP TABLE device_registrations"))
def downgrade():
bind = op.get_bind()
if _column_exists(bind, 'api_device_tokens', 'ios_version'):
op.execute(sa.text(
"ALTER TABLE api_device_tokens DROP COLUMN ios_version"
))
if _column_exists(bind, 'api_device_tokens', 'last_seen_at'):
op.execute(sa.text(
"ALTER TABLE api_device_tokens DROP COLUMN last_seen_at"
))