Jun 26 update to the latest codes
This commit is contained in:
@@ -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 ─────────────────────────────────────────
|
||||||
|
|||||||
@@ -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
@@ -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})
|
||||||
@@ -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})
|
||||||
@@ -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
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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'),
|
||||||
|
|||||||
@@ -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'))
|
||||||
@@ -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'))
|
||||||
@@ -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 %}
|
||||||
@@ -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"
|
||||||
|
))
|
||||||
Reference in New Issue
Block a user