06/23 Implement broadcast function for admin
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
# 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'))
|
||||
Reference in New Issue
Block a user