diff --git a/app/__init__.py b/app/__init__.py index 13106ec..93736d8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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. diff --git a/app/api/__init__.py b/app/api/__init__.py index 178697c..e59e773 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -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) \ No newline at end of file diff --git a/app/api/devices.py b/app/api/devices.py new file mode 100644 index 0000000..04434b8 --- /dev/null +++ b/app/api/devices.py @@ -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}) diff --git a/app/models/device_registration.py b/app/models/device_registration.py new file mode 100644 index 0000000..31238ea --- /dev/null +++ b/app/models/device_registration.py @@ -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')) diff --git a/app/routes/devices.py b/app/routes/devices.py new file mode 100644 index 0000000..6ad7a59 --- /dev/null +++ b/app/routes/devices.py @@ -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')) diff --git a/app/templates/admin/devices.html b/app/templates/admin/devices.html new file mode 100644 index 0000000..2538e54 --- /dev/null +++ b/app/templates/admin/devices.html @@ -0,0 +1,227 @@ +{% extends "base.html" %} + +{% block title %}Device Registry{% endblock %} + +{% block content %} +
+ iOS devices that have opened the JanitorialQC app. + Records update on every app launch — Last Seen reflects the most recent foreground. +
+| Device | +User | +App Ver. | +iOS Ver. | +Last Seen | +Registered | +
|---|---|---|---|---|---|
| + + {{ d.device_name or '—' }} + | +
+ {{ d.user.display_name if d.user else '—' }}
+ {% if d.user %}
+ {{ d.user.username }} + {% endif %} + |
+ + + {{ app_ver }} + + | +{{ d.ios_version or '—' }} | +
+ {{ d.last_seen_at.strftime('%b %-d, %Y') }} + + {{ d.last_seen_at.strftime('%I:%M %p') }} + + |
+ + {{ d.registered_at.strftime('%b %-d, %Y') }} + | +
+ Sends an in-app notification to every user whose device is running + an app version older 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). +
+ + + {% if devices %} +Version breakdown
+ + {% endif %} +