Files
LT_Janitorial_Quality_Control/app/routes/broadcast.py
T

139 lines
5.0 KiB
Python

# 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.routes.broadcast import Broadcast
from app.models.user import User
from app.models.notification import Notification, EVENT_INSPECTION_DONE
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', 'project_manager', 'director', 'admin']
ROLE_LABELS = {
'inspector': '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.')
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 = url_for('broadcast.index', _external=False),
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'))