# app/routes/broadcast.py # ----------------------- # Admin-only route for composing and sending push notifications to all # iOS apps. Uses the existing Notification model and notify() utility — # broadcasts arrive on the iPad via the standard 60-second poll cycle # (GET /api/v1/notifications?since=...) and trigger a local banner via # deliverLocalNotification(). No APNs/FCM required. 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.broadcast import Broadcast from app.models.user import User 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('broadcast', __name__, url_prefix='/admin/broadcast') # All roles that can hold an active iOS session BROADCAST_ROLES = ['inspector', 'external_inspector', 'project_manager', 'director', 'admin'] ROLE_LABELS = { 'inspector': 'Inspectors', 'external_inspector': 'External Inspectors', 'project_manager': 'Project Managers', 'director': 'Directors', 'admin': 'Admins', } @bp.route('/', methods=['GET']) @login_required @admin_required def index(): """Show the compose form and recent broadcast history.""" history = ( Broadcast.query .order_by(Broadcast.sent_at.desc()) .limit(50) .all() ) return render_template( 'admin/broadcast.html', history=history, roles=BROADCAST_ROLES, role_labels=ROLE_LABELS, ) @bp.route('/send', methods=['POST']) @login_required @admin_required def send(): """ Compose and send a broadcast notification. Form fields ----------- title str Notification title (required, max 255) body str Notification body text (required) roles[] list One or more role keys to target (required) """ title = (request.form.get('title') or '').strip() body = (request.form.get('body') or '').strip() target_roles = request.form.getlist('roles') # ── Validation ───────────────────────────────────────────────────────── errors = [] if not title: errors.append('Title is required.') elif len(title) > 255: errors.append('Title must be 255 characters or fewer.') if not body: errors.append('Message body is required.') elif len(body) > 500: errors.append('Message must be 500 characters or fewer.') valid_roles = [r for r in target_roles if r in BROADCAST_ROLES] if not valid_roles: errors.append('Select at least one target role.') if errors: for e in errors: flash(e, 'danger') return redirect(url_for('broadcast.index')) # ── Find target users (active only) ──────────────────────────────────── recipients = ( User.query .filter(User.role.in_(valid_roles), User.active == True) # noqa: E712 .all() ) if not recipients: flash('No active users found for the selected roles.', 'warning') return redirect(url_for('broadcast.index')) # ── Create Notification rows ──────────────────────────────────────────── # One row per recipient — the iOS poll picks them up in the next 60s cycle. # Using the same Notification model as all other in-app notifications means # no iOS code changes are needed: existing deliverLocalNotification() fires # a banner, and unreadNotificationCount increments as usual. for user in recipients: notif = Notification( user_id = user.id, title = title, body = body, link = None, # broadcasts are informational — no destination page event_type = 'admin_broadcast', is_read = False, ) db.session.add(notif) # ── Record the broadcast ──────────────────────────────────────────────── broadcast = Broadcast( title = title, body = body, target_roles = valid_roles, sent_by_id = current_user.id, recipient_count = len(recipients), ) db.session.add(broadcast) db.session.commit() log_action(ACTION_CREATE, 'Broadcast', broadcast.id, f'"{title}" → {", ".join(valid_roles)} ({len(recipients)} users)') logger.info( 'BROADCAST | id=%d | title=%r | roles=%s | recipients=%d | by=%s', broadcast.id, title, valid_roles, len(recipients), current_user.username, ) flash( f'Broadcast sent to {len(recipients)} user(s) across ' f'{", ".join(ROLE_LABELS[r] for r in valid_roles)}.', 'success', ) return redirect(url_for('broadcast.index'))